<?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>MCP server or a curl recipe in AGENTS.md? Measure the breakeven</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 02 Aug 2026 15:30:03 +0000</pubDate>
      <link>https://dev.to/clarkbw--/mcp-server-or-a-curl-recipe-in-agentsmd-measure-the-breakeven-17k0</link>
      <guid>https://dev.to/clarkbw--/mcp-server-or-a-curl-recipe-in-agentsmd-measure-the-breakeven-17k0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The current consensus is that MCP is a token tax and you should just tell the agent to &lt;code&gt;curl&lt;/code&gt; the API from its &lt;code&gt;AGENTS.md&lt;/code&gt;. I measured both, for the same capability, against the same server. The tool schemas cost &lt;strong&gt;+408 tokens standing&lt;/strong&gt; and save &lt;strong&gt;~340–420 tokens per read&lt;/strong&gt;, so the breakeven is roughly &lt;strong&gt;one tool call per turn&lt;/strong&gt; — and with prompt caching (measured, not assumed: a 0.175× effective multiplier across 3,782 real requests) it moves to &lt;strong&gt;one read every five or six turns&lt;/strong&gt;. But the tokens are the easy half and they nearly cancel. What actually decided it was a minus sign. Jump to the breakeven formula.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a good argument going around that MCP servers are a context-window tax — &lt;a href="https://layered.dev/mcp-tool-schema-bloat-the-hidden-token-tax-and-how-to-fix-it/" rel="noopener noreferrer"&gt;tool schemas at ~550–1,400 tokens each&lt;/a&gt;, &lt;a href="https://www.firecrawl.dev/blog/mcp-vs-cli" rel="noopener noreferrer"&gt;benchmarks putting MCP at 4–32× the token cost of an equivalent CLI&lt;/a&gt;, a &lt;a href="https://github.com/modelcontextprotocol/modelcontextprotocol/issues/2808" rel="noopener noreferrer"&gt;spec issue open on the overhead itself&lt;/a&gt;. The conclusion people draw is: skip the server, describe the API in your agent file, let the model shell out.&lt;/p&gt;

&lt;p&gt;I have the same capability implemented both ways, so I measured it instead of arguing about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two ways to let an agent read a boat
&lt;/h2&gt;

&lt;p&gt;The job: an agent that can answer "how much water under the keel?" and "how's the house bank?" from a &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt; server — the open marine data server that normalises NMEA 2000/0183 into one JSON tree.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — an MCP server.&lt;/strong&gt; &lt;code&gt;signalk-mcp&lt;/code&gt; exposes seven named tools. The schemas sit in the prompt every turn:&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;types&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;depth_state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;description&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;Use this for &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;what&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s our depth?&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="s"&gt;how much under the keel?&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="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;how close are we to running aground?&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; — returns water depth with &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;under-keel clearance first. below_keel_m IS the clearance under the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hull (no draft math needed). Do NOT read depth via read_sensor: the &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw transducer path (environment.depth.belowTransducer) is NOT &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;under-keel depth and will mislead. Do not guess depth paths or &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;compute clearance yourself; call this.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;inputSchema&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;type&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;object&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;properties&lt;/span&gt;&lt;span class="sh"&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;Seven of those: &lt;code&gt;get_active_alarms&lt;/code&gt;, &lt;code&gt;read_sensor&lt;/code&gt;, &lt;code&gt;get_route&lt;/code&gt;, &lt;code&gt;battery_state&lt;/code&gt;, &lt;code&gt;depth_state&lt;/code&gt;, &lt;code&gt;get_local_time&lt;/code&gt;, &lt;code&gt;list_paths&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option B — a prose recipe in the agent file.&lt;/strong&gt; No server, no schemas. The agent gets shell access and a section in its always-on &lt;code&gt;AGENTS.md&lt;/code&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;## Reading the boat (SignalK)&lt;/span&gt;

Boat data lives in SignalK on &lt;span class="sb"&gt;`http://localhost:3000`&lt;/span&gt; (anonymous reads, no auth).
Use the exec tool with &lt;span class="sb"&gt;`curl`&lt;/span&gt;. &lt;span class="sb"&gt;`jq`&lt;/span&gt; is available for filtering.
&lt;span class="p"&gt;
-&lt;/span&gt; One value: &lt;span class="sb"&gt;`curl -s http://localhost:3000/signalk/v1/api/vessels/self/&amp;lt;path&amp;gt; | jq '.value'`&lt;/span&gt;
  where the SignalK dotted path becomes slashes.
&lt;span class="p"&gt;-&lt;/span&gt; A whole group at once (fewer calls):
  &lt;span class="sb"&gt;`curl -s http://localhost:3000/signalk/v1/api/vessels/self/electrical | jq`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Top-level groups: &lt;span class="sb"&gt;`navigation`&lt;/span&gt;, &lt;span class="sb"&gt;`environment`&lt;/span&gt;, &lt;span class="sb"&gt;`electrical`&lt;/span&gt;, &lt;span class="sb"&gt;`propulsion`&lt;/span&gt;,
  &lt;span class="sb"&gt;`tanks`&lt;/span&gt;, &lt;span class="sb"&gt;`watermaker`&lt;/span&gt;, &lt;span class="sb"&gt;`communication`&lt;/span&gt;, &lt;span class="sb"&gt;`design`&lt;/span&gt;, &lt;span class="sb"&gt;`notifications`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Units are SI:**&lt;/span&gt; speed m/s, angles &lt;span class="gs"&gt;**radians**&lt;/span&gt;, depth meters, temperature
  &lt;span class="gs"&gt;**Kelvin**&lt;/span&gt;. Convert for the Captain — knots, degrees, °C.
  (m/s→kn ×1.94384; rad→deg ×57.2958; K→°C −273.15.)
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Battery current sign: positive = charging**&lt;/span&gt; (Victron convention).
&lt;span class="p"&gt;-&lt;/span&gt; History (&lt;span class="sb"&gt;`/signalk/v2/history/…`&lt;/span&gt;) is not available — answer "now."
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="gs"&gt;**Never fabricate.**&lt;/span&gt; If curl fails or a path is missing, say so.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both are always-on. Both answer the same questions. Now the receipts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1 — standing cost, the part everyone measures
&lt;/h2&gt;

&lt;p&gt;This is what you pay every turn, including turns where nobody asks about the boat.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;tokens/turn&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;MCP: seven tool schemas&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;874&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent file: the SignalK section&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;466&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Δ — agent file wins&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;−408&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;How I counted — &lt;code&gt;tiktoken&lt;/code&gt;, &lt;code&gt;cl100k_base&lt;/code&gt;, pulling the tool definitions straight out of the source with &lt;code&gt;ast&lt;/code&gt; so there's no hand-transcription:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;
&lt;span class="n"&gt;enc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tiktoken&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_encoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cl100k_base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;src/signalk_mcp/server.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;literal_eval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;kw&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
         &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;node&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;walk&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ast&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="n"&gt;src&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;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Call&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;node&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;func&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;attr&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="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&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;enc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AGENTS.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One honest note on the 874: that's the &lt;code&gt;_list_tools()&lt;/code&gt; definition block as written. Serialised to JSON it's 845; namespaced the way a client renders it (&lt;code&gt;mcp__signalk__depth_state&lt;/code&gt;) it's 887. So call it &lt;strong&gt;845–890 depending on your client&lt;/strong&gt; — the spread doesn't move anything below.&lt;/p&gt;

&lt;p&gt;So far the MCP-is-a-tax crowd is right. 408 tokens a turn, forever, is a real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2 — the part the benchmarks skip
&lt;/h2&gt;

&lt;p&gt;The CLI benchmarks usually stop at the schema. But a tool call has a &lt;em&gt;result&lt;/em&gt;, and the result also lands in the context window.&lt;/p&gt;

&lt;p&gt;Here is what &lt;code&gt;curl&lt;/code&gt; gets back for depth — the whole reason the recipe exists:&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;"transducerToKeel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Depth from the transducer to the bottom of the keel"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"units"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"displayUnits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"depth"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"targetUnit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"formula"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"inverseFormula"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"symbol"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
                &lt;/span&gt;&lt;span class="nl"&gt;"displayFormat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.0"&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="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;-1.12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"$source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"defaults"&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-08-01T13:35:02.076Z"&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;"surfaceToTransducer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;meta&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;again...&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;"belowTransducer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;meta&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;again...&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;"belowKeel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;           &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...plus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;alarmMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;warnMethod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;three&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;zones...&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;"belowSurface"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...same&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;meta&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;block&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;again...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;515 tokens, of which the five &lt;code&gt;value&lt;/code&gt; fields are about a dozen. Everything else is &lt;code&gt;meta&lt;/code&gt; — &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;units&lt;/code&gt;, and a &lt;code&gt;displayUnits&lt;/code&gt; object carrying &lt;code&gt;formula&lt;/code&gt;, &lt;code&gt;inverseFormula&lt;/code&gt;, &lt;code&gt;symbol&lt;/code&gt; and &lt;code&gt;displayFormat&lt;/code&gt; — repeated per leaf. The agent pays for all of it and then has to find the numbers inside.&lt;/p&gt;

&lt;p&gt;The MCP tool returns this instead:&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;"below_keel_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;36.42532195742289&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"below_surface_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;37.795321957422885&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"below_transducer_m"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;37.545321957422885&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;"36.4 metres under the keel, 37.8 metres total depth"&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-08-01T18:37:26.629Z"&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;92 tokens. Same numbers, plus a TTS-safe sentence the voice pipeline can read out verbatim.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;read&lt;/th&gt;
&lt;th&gt;raw &lt;code&gt;curl&lt;/code&gt; response&lt;/th&gt;
&lt;th&gt;MCP tool result&lt;/th&gt;
&lt;th&gt;ratio&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;environment/depth&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;515&lt;/td&gt;
&lt;td&gt;92&lt;/td&gt;
&lt;td&gt;5.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;electrical&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;431&lt;/td&gt;
&lt;td&gt;93&lt;/td&gt;
&lt;td&gt;4.6×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;navigation&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;627&lt;/td&gt;
&lt;td&gt;~92&lt;/td&gt;
&lt;td&gt;6.8×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You can't &lt;code&gt;jq&lt;/code&gt; your way out of most of this, either. Piping to &lt;code&gt;jq '.value'&lt;/code&gt; works for a single leaf, but the recipe's own advice — grab the whole subtree in one call to save round trips — is exactly the case where the meta bloat arrives in full.&lt;/p&gt;

&lt;h2&gt;
  
  
  The breakeven formula
&lt;/h2&gt;

&lt;p&gt;Two levers pulling opposite directions. Set them against each other.&lt;/p&gt;

&lt;p&gt;Let &lt;strong&gt;ΔS&lt;/strong&gt; = the standing-cost difference (schemas − prose) and &lt;strong&gt;ΔR&lt;/strong&gt; = the per-read saving (raw response − tool result). Cost per turn at &lt;em&gt;k&lt;/em&gt; reads:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agent file:   P + k · raw
MCP server:   S + k · tool

MCP is cheaper when   S + k·tool  &amp;lt;  P + k·raw
                             k    &amp;gt;  (S − P) / (raw − tool)
                             k*   =  ΔS / ΔR
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this pair: &lt;strong&gt;k* = 408 / ~400 ≈ 1.02 reads per turn.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's the finding. Not "MCP good" or "MCP is a tax" — &lt;em&gt;it pays for itself at roughly one tool call per turn.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Which cuts cleanly in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A general-purpose agent that touches the boat on one turn in twenty: the agent file is cheaper, and it isn't close.&lt;/li&gt;
&lt;li&gt;An agent whose entire job is the boat, where essentially every turn is a read: the MCP wins outright.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My constants aren't yours. Run the same three measurements on your own server — schema tokens, prose tokens, and a representative raw-vs-tool response pair — and divide. If your API returns lean JSON, ΔR collapses and the prose recipe wins at almost any &lt;em&gt;k&lt;/em&gt;. If your API is as metadata-heavy as SignalK's, ΔR is large and k* drops below 1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt caching moves k*, and I measured by how much
&lt;/h2&gt;

&lt;p&gt;The standing cost is the MCP's only loss — and it's the exact part that caches. Tool schemas are a stable prefix. The per-read saving is on churny tokens that never cache. So caching should shrink ΔS and leave ΔR alone, which moves k* down.&lt;/p&gt;

&lt;p&gt;The tempting shortcut is to call cached tokens "10% of input" and be done. That's wrong, because it only counts the read side. A cache entry has to be &lt;em&gt;written&lt;/em&gt; first, and writes cost &lt;strong&gt;more&lt;/strong&gt; than uncached input.&lt;/p&gt;

&lt;p&gt;So rather than assume a hit rate, I aggregated the &lt;code&gt;usage&lt;/code&gt; field across the &lt;strong&gt;40 most recent agent session transcripts&lt;/strong&gt; for this project — &lt;strong&gt;3,782 billed requests&lt;/strong&gt; on a real runtime with &lt;code&gt;signalk-mcp&lt;/code&gt; attached:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cache READ    698,670,375 tokens   94.33% of input-side
cache WRITE    41,945,413 tokens    5.66%
uncached input     29,139 tokens    ~0.00%

cold requests (zero cache read):  150 / 3,782  =  4.0%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every write in that sample was &lt;strong&gt;1-hour TTL&lt;/strong&gt; (&lt;code&gt;ephemeral_1h_input_tokens&lt;/code&gt;; &lt;code&gt;ephemeral_5m&lt;/code&gt; was zero), which matters — the price multiplier depends on TTL. Per the &lt;a href="https://docs.claude.com/en/docs/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;prompt-caching docs&lt;/a&gt;: cache read &lt;strong&gt;0.1×&lt;/strong&gt; base input, 5-minute write &lt;strong&gt;1.25×&lt;/strong&gt;, 1-hour write &lt;strong&gt;2×&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A standing block gets written on cold turns and read on warm ones, so its effective multiplier is &lt;code&gt;cold × write + warm × read&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;cold turns&lt;/td&gt;
&lt;td&gt;4.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;warm turns&lt;/td&gt;
&lt;td&gt;96.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;standing-block effective multiplier (1h TTL — what we ran)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.175×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same block at 5-minute TTL&lt;/td&gt;
&lt;td&gt;0.146×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;blended multiplier across all input-side tokens&lt;/td&gt;
&lt;td&gt;0.208×&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Apply that to the 408-token standing delta and re-run the formula:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;408 × 0.175  =  ~72 effective tokens

k* = 72 / 400  ≈  0.18 reads per turn
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;About one boat read every five or six turns.&lt;/strong&gt; Not the one-in-ten you get from the flat-10% shortcut — the write side is the expensive half and at 1-hour TTL it costs 2×.&lt;/p&gt;

&lt;p&gt;Caching still makes the MCP case &lt;em&gt;stronger&lt;/em&gt;, just less dramatically than the naive math suggests. And it's conservative in the MCP's favour for a second reason the formula doesn't capture: a tool result is uncached only on the turn it &lt;em&gt;arrives&lt;/em&gt;. After that it joins the cached prefix at 0.175× for the rest of the session. The ~400-tokens-per-read saving counts only the first turn; the MCP's leaner results keep paying rent afterwards.&lt;/p&gt;

&lt;p&gt;Three things to be honest about before you reuse the 0.175×:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;That's a hot session.&lt;/strong&gt; 96% warm is what sustained back-to-back work produces. A boat agent DM'd over Telegram with minutes between messages cold-starts far more often — the 5-minute TTL expiring between turns is a problem I've &lt;a href="https://dev.to%20post_url%202026-07-29-openclaw-raspberry-pi-signalk-boat-agent-gateway-mode-local-si-units-tools-profile-prompt-caching%20"&gt;hit before&lt;/a&gt;. So 0.175× is the &lt;em&gt;favourable&lt;/em&gt; end. A bursty agent lands nearer the uncached case — which widens the MCP's advantage rather than narrowing it: the standing penalty grows back toward 408, but so does the value of never re-sending 500-token blobs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime attribution:&lt;/strong&gt; these are Claude Code sessions carrying the MCP, not the boat agent itself. Real cache behaviour of a real agent holding these schemas — but I didn't instrument the boat agent, and I'm not claiming I did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 1-hour TTL is a property of this session,&lt;/strong&gt; not a universal default. On the 5-minute default the multiplier is 0.146× and the standing penalty is ~59 tokens, a slightly cheaper MCP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One takeaway for reading other people's numbers: the MCP-vs-CLI benchmarks I can find quote schema cost &lt;strong&gt;uncached&lt;/strong&gt;. On this measured multiplier that overstates the standing penalty by about 5.7×.&lt;/p&gt;

&lt;h2&gt;
  
  
  The half that tokens don't measure
&lt;/h2&gt;

&lt;p&gt;Here's the thing that actually decided it, live off the boat's &lt;code&gt;environment.depth&lt;/code&gt; tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;environment.depth.belowTransducer   =  38.5187
environment.depth.transducerToKeel  =  -1.12      &amp;lt;-- stored NEGATIVE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The keel offset is stored as a &lt;strong&gt;negative&lt;/strong&gt; number. Under-keel clearance is therefore an &lt;em&gt;addition&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;belowTransducer + transducerToKeel  =  38.5187 + (-1.12)  =  37.40 m   ✅
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An agent reading the raw tree and doing the obvious thing — &lt;em&gt;depth below the transducer, minus the keel offset&lt;/em&gt; — gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;38.5187 - (-1.12)  =  39.64 m   ❌
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;It reports 2.24 metres more clearance than the boat actually has, and the error points toward grounding.&lt;/strong&gt; In 38 m of water that's a rounding error. In 3 m it's the whole margin, delivered in a confident voice.&lt;/p&gt;

&lt;p&gt;Nothing in the token comparison sees this. The wrong answer costs the same as the right one.&lt;/p&gt;

&lt;p&gt;The MCP does the arithmetic in Python, where it can't be dropped:&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;below_keel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;below_keel_obj&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="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="c1"&gt;# SignalK's own belowKeel leaf
&lt;/span&gt;&lt;span class="bp"&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="nf"&gt;_depth_display&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;below_keel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;below_surface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;below_transducer&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;— and the tool description spends tokens specifically to stop the model freelancing: &lt;em&gt;"Do NOT read depth via read_sensor: the raw transducer path is NOT under-keel depth and will mislead. Do not guess depth paths or compute clearance yourself; call this."&lt;/em&gt; That instruction is part of the 874. It's not schema bloat; it's the thing being bought.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the prose recipe is really asking for
&lt;/h2&gt;

&lt;p&gt;Look back at the agent file with this in mind. Every bullet is a rule the model has to &lt;em&gt;remember and apply by hand&lt;/em&gt;, on every read, forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;m/s → knots          × 1.94384
radians → degrees    × 57.2958
Kelvin → °C          − 273.15
battery current      positive = charging (Victron convention)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four conversions, plus a sign convention, plus the keel-offset sign that isn't even in the list because nobody thought to write it down. Each is a coin flip on a bad turn — a long context, a distracting question, a smaller model. And the failure is silent: the model returns a number, not an error. "38 degrees" when the value was radians reads perfectly plausibly.&lt;/p&gt;

&lt;p&gt;In Python those are five lines that execute the same way every time:&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="n"&gt;tail&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_SPEED_KEYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;value&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;1.94384&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; knots&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;knots&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tail&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_BEARING_KEYS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;deg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;degrees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;
    &lt;span class="k"&gt;return&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;deg&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;° (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;_degrees_to_compass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;deg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&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;value&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;273.15&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;°C&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;°C&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the summary is: &lt;strong&gt;the agent file is cheaper to carry and more expensive to be wrong with.&lt;/strong&gt; The token comparison is the easy half and it nearly cancels. The deciding factor is where the domain knowledge lives — in prose the model may skip, or in code it can't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats, before you quote my numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The vessel is ashore.&lt;/strong&gt; This is Phase 0; the SignalK server runs a mock vessel plugin. The token counts, response shapes and the &lt;code&gt;transducerToKeel&lt;/code&gt; sign convention are all real. The &lt;em&gt;readings&lt;/em&gt; are synthetic — nobody is in 38 m of water off Boundary Pass right now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cl100k_base is a proxy.&lt;/strong&gt; Your model's tokenizer will give slightly different numbers. The ratios hold; the absolutes won't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;874 vs 466 is my seven-tool server against my recipe.&lt;/strong&gt; The formula generalises. The constants absolutely do not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is not a controlled A/B.&lt;/strong&gt; The two setups differ in more than MCP-vs-file — different runtimes, Python vs &lt;code&gt;curl&lt;/code&gt;. It's a like-for-like cost comparison of two ways to give an agent the same capability, not a single-variable experiment.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where it lands
&lt;/h2&gt;

&lt;p&gt;I kept the MCP server, and the reason isn't the tokens — on tokens it's a coin flip that caching tips slightly my way. I kept it because &lt;code&gt;depth_state&lt;/code&gt; cannot get the sign wrong, and a prose bullet can.&lt;/p&gt;

&lt;p&gt;The rule of thumb I'd offer: &lt;strong&gt;compute k* = ΔS / ΔR before you argue about it&lt;/strong&gt;, and then ask the question the tokens can't answer — &lt;em&gt;is there any arithmetic in this domain that the model must not get wrong?&lt;/em&gt; If yes, that decides it regardless of which side of the breakeven you land on.&lt;/p&gt;

&lt;p&gt;The server is &lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-mcp&lt;/code&gt;&lt;/a&gt; (MIT), part of the agent stack I'm building for an all-electric charter catamaran that doesn't exist yet.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-06-signalk-mcp-named-tools-vs-execute-code-token-efficiency-voice-agent%20"&gt;Why we kept named MCP tools despite a 96% token saving&lt;/a&gt; — the same tradeoff one level down, inside the MCP server; &lt;a href="https://dev.to%20post_url%202026-07-29-openclaw-raspberry-pi-signalk-boat-agent-gateway-mode-local-si-units-tools-profile-prompt-caching%20"&gt;Running OpenClaw on a Raspberry Pi alongside SignalK&lt;/a&gt; — where the curl recipe comes from, plus AGENTS.md vs SKILL.md measured the same way; and &lt;a href="https://dev.to%20post_url%202026-06-11-agent-skill-body-vs-base-system-prompt-always-on-conditional-deploy%20"&gt;Why your agent ignores its skill body but obeys the system prompt&lt;/a&gt; — always-on vs conditional, the first axis of this question.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
    </item>
    <item>
      <title>A persistent-queue HTTP reporter for distress traffic over flaky internet</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sat, 01 Aug 2026 16:05:03 +0000</pubDate>
      <link>https://dev.to/clarkbw--/a-persistent-queue-http-reporter-for-distress-traffic-over-flaky-internet-57nl</link>
      <guid>https://dev.to/clarkbw--/a-persistent-queue-http-reporter-for-distress-traffic-over-flaky-internet-57nl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — Don't &lt;code&gt;fetch()&lt;/code&gt;-and-forget safety-relevant reports over marine internet. &lt;code&gt;signalk-distress-core&lt;/code&gt; ships a generic persistent-queue HTTP reporter: every payload is appended to a JSONL file &lt;em&gt;before&lt;/em&gt; the POST, delivered sequentially in order, retried forever on network errors, retried with a cap on 5xx, and dropped immediately on 400/404. The queue survives crashes and restarts. Jump to the response policy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this matters for safety at sea
&lt;/h2&gt;

&lt;p&gt;When a vessel in trouble hits the red distress button on a DSC VHF radio — or an EPIRB, SART, or MOB beacon starts transmitting on AIS — every receiver in range gets a copy. Shore stations hear most of it. But VHF is line-of-sight: behind an island, deep in a fjord, or simply far enough offshore, a distress burst can be heard by a nearby boat &lt;em&gt;and missed by every shore station&lt;/em&gt;. The copy your radio captured may be the only one with a position in it that made it anywhere.&lt;/p&gt;

&lt;p&gt;That's the case for relaying received distress traffic to a shore-side aggregation service: a crowd of receivers extends coverage beyond what any single station — afloat or ashore — can hear. &lt;a href="https://dscwatch.com" rel="noopener noreferrer"&gt;DSCWatch&lt;/a&gt; is one such network: it aggregates received DSC traffic from stations around the world into coverage maps and distress logs, and &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt; can submit every call it hears.&lt;/p&gt;

&lt;p&gt;But now look at the chain: radio → boat server → &lt;strong&gt;boat internet&lt;/strong&gt; → aggregation service. On a boat, the uplink is the least reliable link in that chain. Cellular dies in exactly the remote places where your copy of a distress call matters most. Satellite drops in rain, in swell, behind terrain. The server power-cycles with the house bank. If the reporting code is a bare &lt;code&gt;fetch()&lt;/code&gt;, every one of those blinks silently discards a heard distress call — and &lt;em&gt;"we dropped a MAYDAY on the floor because the internet blinked"&lt;/em&gt; is not an acceptable failure mode.&lt;/p&gt;

&lt;p&gt;So the delivery requirements write themselves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Write-through persisted&lt;/strong&gt; — the report is on disk before the first delivery attempt, so a crash mid-flight loses nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restart-proof&lt;/strong&gt; — whatever was undelivered at shutdown is delivered after startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-order&lt;/strong&gt; — DSC position &lt;em&gt;refinements&lt;/em&gt; (&lt;code&gt;$--DSE&lt;/code&gt;) must arrive after the alerts they refine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Offline catch-up&lt;/strong&gt; — hours of outage queue up and drain when connectivity returns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's a generic problem, not a DSC problem. So it lives in &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-distress-core&lt;/code&gt;&lt;/a&gt; (v0.5.0) as a payload-agnostic module — &lt;code&gt;signalk-dsc&lt;/code&gt; maps its events onto DSCWatch report bodies and calls &lt;code&gt;report()&lt;/code&gt;; &lt;a href="https://github.com/sailingnaturali/signalk-ais-distress" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ais-distress&lt;/code&gt;&lt;/a&gt; can reuse it as-is. The rest of this post is the engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The naive versions, and why each one fails
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fire-and-forget&lt;/strong&gt; is where everyone starts:&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;// Looks fine on shore wifi. On a boat it's a data shredder.&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;report&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;catch&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Offline for six hours crossing a strait? Six hours of received calls are gone. Process restarts? Anything in flight is gone. And on a boat both of those are &lt;em&gt;routine&lt;/em&gt;, not exceptional.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Retry in memory&lt;/strong&gt; is the next instinct — keep an array, retry on failure:&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;queue&lt;/span&gt; &lt;span class="o"&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;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;flushSoon&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;Better, until the process exits — SignalK restarts whenever you save any plugin's configuration, and the whole server power-cycles with the boat's electrical system. Everything queued is gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Batch to disk on a timer&lt;/strong&gt; is the classic telemetry answer: buffer, flush every N seconds. But the window between "received" and "persisted" is exactly the crash window, and for distress traffic the acceptable size of that window is zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write-through, not batching
&lt;/h2&gt;

&lt;p&gt;The reporter appends the payload to a JSONL queue file &lt;em&gt;first&lt;/em&gt;, then immediately kicks the flusher. A healthy network still sees the POST within milliseconds; the disk write just happens to come before 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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Enqueue and kick the flusher. Fire-behind: never throws. */&lt;/span&gt;
  &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&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="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;attempts&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queue&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;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;maxQueue&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;maxQueue&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&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;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`reporter: enqueue failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;started&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;flush&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;started&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;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nx"&gt;timer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;Three deliberate choices in those few lines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;appendFileSync&lt;/code&gt;, one JSON object per line.&lt;/strong&gt; Append is the cheapest durable write there is, and JSONL means a partial write corrupts &lt;em&gt;one line&lt;/em&gt;, not the file (more on that below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fire-behind, never throws.&lt;/strong&gt; The caller is a hot path handling a distress alert — raising alarms, writing the logbook. Reporting must never take that path down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;attempts&lt;/code&gt; is in-memory only.&lt;/strong&gt; The disk format is just payloads. Retry accounting resets on restart, which is fine — a restart is a fresh chance, not a continuation of a grudge.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Delivery is sequential and in order — one POST at a time, head of the queue first — so a &lt;code&gt;$--DSE&lt;/code&gt; position refinement can never overtake the distress alert it refines.&lt;/p&gt;

&lt;h2&gt;
  
  
  The response policy: what each HTTP answer means
&lt;/h2&gt;

&lt;p&gt;The core design decision is that &lt;strong&gt;"the POST failed" is three different situations&lt;/strong&gt;, and each one gets a different answer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Outcome&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;2xx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delivered (200 merged / 201 created)&lt;/td&gt;
&lt;td&gt;Dequeue, persist, reset backoff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;400&lt;/code&gt; / &lt;code&gt;404&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Bad body or rejected receiver key — a retry cannot fix it&lt;/td&gt;
&lt;td&gt;Drop it, move on (404 also signals the plugin once)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;5xx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Server reachable but erroring&lt;/td&gt;
&lt;td&gt;Retry with backoff, capped per entry&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network error / timeout&lt;/td&gt;
&lt;td&gt;Offline — the marine normal&lt;/td&gt;
&lt;td&gt;Keep &lt;strong&gt;everything&lt;/strong&gt;, back off, catch up later&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's the flush loop that implements 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;flush&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="nx"&gt;flushing&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="nx"&gt;flushing&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;entry&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;queue&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;res&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="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&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;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
          &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Network down (the offline-passage case): keep everything —&lt;/span&gt;
        &lt;span class="c1"&gt;// whatever is behind the head is failing for the same reason,&lt;/span&gt;
        &lt;span class="c1"&gt;// so in-order blocking costs nothing. Retry on a growing timer.&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="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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`reporter: network error (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) — &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; queued`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nf"&gt;backoff&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="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="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="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;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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nx"&gt;backoffMs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;backoffBaseMs&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;permanentSignaled&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="k"&gt;continue&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="o"&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;status&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="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// A retry cannot fix a bad body or a rejected receiver key.&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`reporter: dropped report (HTTP &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;status&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="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;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;404&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;permanentSignaled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="nx"&gt;permanentSignaled&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;onPermanentError&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;status&lt;/span&gt;&lt;span class="p"&gt;);&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="p"&gt;}&lt;/span&gt;
      &lt;span class="c1"&gt;// Server reachable but erroring (5xx): retry with backoff, but cap&lt;/span&gt;
      &lt;span class="c1"&gt;// per entry — one poison payload must not block the queue behind it.&lt;/span&gt;
      &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;attempts&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`reporter: dropped report after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; attempts (HTTP &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;status&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="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;backoff&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;flushing&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="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 asymmetry is the point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Network errors never consume retry attempts.&lt;/strong&gt; Being offline for a nine-hour passage is not the payload's fault, so nothing is dropped for it, ever (up to &lt;code&gt;maxQueue&lt;/code&gt;). Head-of-line blocking is &lt;em&gt;free&lt;/em&gt; here — everything behind the head would fail for the same reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;5xx retries are capped per entry&lt;/strong&gt; (&lt;code&gt;maxAttempts&lt;/code&gt;, default 10). Without the cap, one poison payload that reliably 500s would block every report behind it forever. With it, in-order delivery yields to liveness after a bounded effort.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;400/404 are dropped on the first response.&lt;/strong&gt; A malformed body or an unknown receiver key will be exactly as malformed on attempt fifty. Retrying a permanent rejection just hammers someone else's server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;404 signals the plugin exactly once&lt;/strong&gt; (&lt;code&gt;onPermanentError&lt;/code&gt; → &lt;code&gt;app.setPluginStatus&lt;/code&gt; in &lt;code&gt;signalk-dsc&lt;/code&gt;), and a later success resets the once-guard. Reports keep being attempted, so fixing the receiver key or the endpoint URL in config heals delivery without a restart — no "notification spam" and no "silently wedged until reboot".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last point is the same lesson as our &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;ntfy 401 incident&lt;/a&gt;: a delivery path that fails must &lt;em&gt;say so&lt;/em&gt; somewhere a human looks, exactly once, and recover by itself when the config is fixed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Surviving restarts: reload, torn lines, and the trim
&lt;/h2&gt;

&lt;p&gt;On construction the reporter reloads whatever the last process left behind:&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&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="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;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;attempts&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="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// torn/corrupt line — skip it, keep the rest&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// no queue yet&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;torn-line skip&lt;/strong&gt; is why the disk format is JSONL and not one big JSON array: if the power died mid-append, exactly one line is unparseable. &lt;code&gt;JSON.parse&lt;/code&gt; per line means that line is skipped and every intact report before and after it still delivers. A JSON array with a torn tail would lose the whole file.&lt;/p&gt;

&lt;p&gt;Rewrites (dequeue, trim) go through a &lt;strong&gt;temp-file-and-rename&lt;/strong&gt; &lt;code&gt;persist()&lt;/code&gt; so the queue file is never observed half-written:&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;persist&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;tmp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.tmp`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdirSync&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;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;recursive&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;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;writeFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;renameSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tmp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queueFile&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;And &lt;strong&gt;&lt;code&gt;maxQueue&lt;/code&gt; (default 5000) drops oldest first&lt;/strong&gt; when a long outage overflows it. Newest-wins is the right policy for distress relay: the most recent calls are the ones a shore-side service can still act on, and 5000 queued reports is weeks of traffic for most stations anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that shipped in v0.5.0: the stale-flusher restart race
&lt;/h2&gt;

&lt;p&gt;v0.5.0 shipped with the design above and a green test suite — and a race that could destroy the queue file. It was caught the same morning, reviewing the shutdown path, and fixed in v0.5.1.&lt;/p&gt;

&lt;p&gt;The scenario: SignalK restarts a plugin whenever its configuration is saved. That calls &lt;code&gt;stop()&lt;/code&gt; on the old reporter instance and constructs a new one — which loads the queue file and starts appending to it. But &lt;code&gt;stop()&lt;/code&gt; can land &lt;strong&gt;while the old instance's POST is still in flight&lt;/strong&gt;. The old flusher is parked on &lt;code&gt;await fetchImpl(...)&lt;/code&gt;. When that stale fetch finally resolves:&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;// v0.5.0 — the resolving await charges ahead:&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;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;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shift&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;persist&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;   &lt;span class="c1"&gt;// rewrites the queue file from the OLD instance's memory&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;persist()&lt;/code&gt; rewrites the whole file from the &lt;em&gt;old&lt;/em&gt; instance's in-memory queue — silently deleting every report the &lt;em&gt;new&lt;/em&gt; instance had appended since. A config tweak at the wrong millisecond erases a heard distress call from the queue. Exactly the failure mode this module exists to prevent, reintroduced by its own cleanup path.&lt;/p&gt;

&lt;p&gt;The fix is two guards — re-check &lt;code&gt;started&lt;/code&gt; after every &lt;code&gt;await&lt;/code&gt; boundary before touching shared state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;      } catch (err) {
        // Network down ...
&lt;span class="gi"&gt;+       if (!started) return;
&lt;/span&gt;        log(`reporter: network error (${err.message}) — ${queue.length} queued`);
        backoff();
        return;
      }
&lt;span class="gi"&gt;+     if (!started) return;
&lt;/span&gt;      if (res.ok) {
        queue.shift();
        persist();
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The catch-branch guard matters too: without it, a network error resolving on a stopped reporter schedules a fresh backoff timer — a zombie instance that wakes up later and races the live one.&lt;/p&gt;

&lt;p&gt;Two consequences worth naming:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The in-flight entry stays queued for the new instance&lt;/strong&gt;, so it may be POSTed twice. That's deliberate: the DSCWatch backend deduplicates repeated submissions of the same call (DSC alerts auto-repeat on air anyway, and every repeat is reported), so at-least-once is the correct choice over at-most-once. For distress traffic, a duplicate is noise; a loss is the failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;This is the generic &lt;code&gt;async&lt;/code&gt; shutdown lesson&lt;/strong&gt;, nothing marine about it: any &lt;code&gt;await&lt;/code&gt; in a loop is a suspension point where the world can change — including "you were stopped and replaced". Every resumption that mutates shared state (a file, a socket, a DB row) has to re-validate that it still owns that state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And the race got a test — a gated fetch that holds the POST in flight across a &lt;code&gt;stop()&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stop() during an in-flight POST: the stale flusher never touches the queue file&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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;queueFile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tmpQueue&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;resolveFetch&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;gate&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;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&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;resolveFetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;r&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;impl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&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;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;gate&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// hold the POST in flight&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;reporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ua&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seq&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;eventually&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;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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="c1"&gt;// fetch is in flight&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// plugin restart: a new instance may now own the file&lt;/span&gt;
  &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;seq&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;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// new instance's append&lt;/span&gt;
  &lt;span class="nf"&gt;resolveFetch&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// stale POST finally resolves&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&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;onDisk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readFileSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;trim&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="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;l&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;JSON&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;l&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;seq&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;onDisk&lt;/span&gt;&lt;span class="p"&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="mi"&gt;99&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt; &lt;span class="c1"&gt;// stale flusher must NOT have shifted/persisted&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fetch timeouts: a black-holed link must not wedge the flusher
&lt;/h2&gt;

&lt;p&gt;v0.5.1 also added the one thing a default Node &lt;code&gt;fetch&lt;/code&gt; won't give you — a deadline:&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&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;Content-Type&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;application/json&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;User-Agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userAgent&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AbortSignal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fetchTimeoutMs&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;// default 30 s&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Marine links don't just go down — they &lt;em&gt;black-hole&lt;/em&gt;: a satellite terminal that accepts the TCP connection and then says nothing more, sometimes for minutes. Without a timeout, the single sequential flusher sits parked on that &lt;code&gt;await&lt;/code&gt; indefinitely, and the whole queue stalls behind a connection that will never answer. &lt;code&gt;AbortSignal.timeout()&lt;/code&gt; (stdlib since Node 17.3, no &lt;code&gt;AbortController&lt;/code&gt; boilerplate needed) turns that into an abort error, which takes the existing network-error path: keep the entry, back off, retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests pin the policy, not the implementation
&lt;/h2&gt;

&lt;p&gt;The response policy &lt;em&gt;is&lt;/em&gt; the contract, so the tests assert it directly against a scriptable &lt;code&gt;fetch&lt;/code&gt; stand-in — no HTTP server, no mocking framework, just &lt;code&gt;node:test&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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;400 is dropped without retry; the next report still delivers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mockFetch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;call&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;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bad&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;reporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ua&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tmpQueue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;bad&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;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;good&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;eventually&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;// bad tried exactly once&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calls&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="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;good&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="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;5xx retries are capped per entry, then the entry drops and the queue moves on&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;mockFetch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;call&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;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;poison&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;201&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;reporter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createReporter&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;u&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userAgent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ua&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;queueFile&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;tmpQueue&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;fetchImpl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;impl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;maxAttempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoffBaseMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;backoffMaxMs&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="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;poison&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;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;after&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;eventually&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;poisonTries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;poison&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;poisonTries&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="c1"&gt;// exactly maxAttempts, then dropped&lt;/span&gt;
    &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;calls&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;after&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="p"&gt;});&lt;/span&gt;
  &lt;span class="nx"&gt;reporter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&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 full suite pins every row of the policy table: 200-and-201 both accepted, in-order delivery, offline catch-up resuming in order with nothing dropped, restart recovery from the JSONL file, torn-line skip, &lt;code&gt;maxQueue&lt;/code&gt; oldest-first trim, the 404 signal-once-reset-on-success behaviour, and the stale-flusher race above. &lt;code&gt;fetchImpl&lt;/code&gt; is injectable for exactly this reason — the only seam the module needs.&lt;/p&gt;

&lt;p&gt;Two small design notes riding along in the same release:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The receiver key is minted once and persisted&lt;/strong&gt; (&lt;code&gt;loadOrCreateReceiverKey&lt;/code&gt;): DSCWatch has no registration call — the first report for a new key creates the receiver record — so the only rule is &lt;em&gt;reuse the same value forever&lt;/em&gt;. A UUID written to a file in the plugin data directory does that; a station that wants attribution can configure its own key instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What leaves the boat is a pick-list, not a filter.&lt;/strong&gt; The report builder in &lt;code&gt;signalk-dsc&lt;/code&gt; copies an explicit allow-list of parsed call fields; local-only data (own-ship weather snapshots, spoken alert text, internal IDs) is never sent because it is never picked. Privacy boundaries built as "copy what's allowed" don't rot the way "strip what's forbidden" does. Reporting is on by default with a single toggle to keep all data on the boat — see the &lt;a href="https://github.com/sailingnaturali/signalk-dsc#dscwatch-reporting" rel="noopener noreferrer"&gt;plugin README&lt;/a&gt; for the exact field list.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Gotchas if you build one of these
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Append-before-POST, always.&lt;/strong&gt; If the durable write isn't strictly first, there is a crash window, and on a boat the crash window gets hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify responses; don't treat "not ok" as one case.&lt;/strong&gt; Permanent rejections (400/404) retried forever are abuse; transient errors (5xx, timeouts) dropped immediately are data loss. The policy needs both, explicitly, with tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap retries per entry, not globally.&lt;/strong&gt; The cap exists to defeat poison-payload head-of-line blocking; a global cap would let one bad payload spend the whole budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check ownership after every &lt;code&gt;await&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;stop()&lt;/code&gt; + in-flight I/O + shared file = the stale-flusher race. Guard every resumption that touches shared state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;timer.unref()&lt;/code&gt; the backoff timer&lt;/strong&gt; so a pending retry never keeps the host process alive past shutdown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSONL over a JSON array&lt;/strong&gt; for any on-disk queue: torn writes cost one record, not the file.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This runs on the same all-electric-catamaran SignalK stack as the rest of this blog, relaying what the radio hears toward people who can use it. The reporter is payload-agnostic and MIT-licensed: &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;&lt;code&gt;@sailingnaturali/signalk-distress-core&lt;/code&gt;&lt;/a&gt; (&lt;code&gt;lib/reporter.js&lt;/code&gt;, ~180 lines, tests included), consumed by &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt; for DSCWatch reporting.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt; how the DSC calls get captured in the first place — &lt;a href="https://dev.to%20post_url%202026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808%20"&gt;Logging VHF DSC distress calls in SignalK (PGN 129808)&lt;/a&gt; — and the delivery-path-monitoring lesson this reporter inherits — &lt;a href="https://dev.to%20post_url%202026-07-01-ntfy-401-silent-push-failure-delivery-path-health-check-heartbeat-dead-mans-switch%20"&gt;Monitor the delivery path, not just the alarm&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>dsc</category>
      <category>marine</category>
      <category>node</category>
    </item>
    <item>
      <title>Offline tidal currents from harmonic constituents — and when not to trust them</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Fri, 31 Jul 2026 16:05:43 +0000</pubDate>
      <link>https://dev.to/clarkbw--/offline-tidal-currents-from-harmonic-constituents-and-when-not-to-trust-them-1j0p</link>
      <guid>https://dev.to/clarkbw--/offline-tidal-currents-from-harmonic-constituents-and-when-not-to-trust-them-1j0p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-currents&lt;/code&gt;&lt;/a&gt; v0.7.0 keeps publishing tidal-current predictions when the boat loses internet: it bundles public-domain NOAA harmonic constituents and synthesizes slack/flood/ebb events offline with &lt;a href="https://github.com/neaps/neaps" rel="noopener noreferrer"&gt;Neaps&lt;/a&gt;. But every fallback reading is labeled &lt;code&gt;source: "harmonic"&lt;/code&gt;, &lt;code&gt;live: false&lt;/code&gt;, and — at constricted passes — &lt;code&gt;unreliableForTransit: true&lt;/code&gt;, because harmonic slack timing at a tidal rapids can be off by tens of minutes. While the network &lt;em&gt;is&lt;/em&gt; up, the plugin logs harmonic-vs-live discrepancies locally so the error is measured, not guessed. Jump to the build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Marine software has one non-negotiable requirement that most connected software gets to ignore: it has to keep working when the internet doesn't. A tidal-currents plugin that answers "when is slack at the pass?" only while the LTE link is up is worse than a paper tide book — because you'll have built your habits around asking it.&lt;/p&gt;

&lt;p&gt;This post is the deeper look promised in the &lt;a href="https://github.com/sailingnaturali/signalk-currents#offline-harmonic-fallback" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-currents&lt;/code&gt; README&lt;/a&gt;: how the offline harmonic fallback works, why it deliberately announces its own unreliability at constricted passes instead of silently degrading, and how it measures itself against live data while it still can.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;signalk-currents&lt;/code&gt; publishes &lt;code&gt;environment.current&lt;/code&gt; and a &lt;code&gt;/currents&lt;/code&gt; resource by fetching &lt;strong&gt;live&lt;/strong&gt; predictions from the CHS (Canada) and NOAA (US) tides-and-currents APIs, one fetch per station per UTC day. Every poll needs connectivity. Kill the uplink and the failure looks like this in the server log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signalk-currents: station Seymour Narrows live fetch failed: fetch failed
signalk-currents: station Boundary Pass live fetch failed: fetch failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The per-day cache papers over short outages — per-day predictions are immutable, so anything already fetched keeps serving — but it's an in-memory &lt;code&gt;Map&lt;/code&gt; scoped to the process. Restart the server while offline, or sail past the configured &lt;code&gt;horizonDays&lt;/code&gt;, and currents go dark. On a boat, "restart while offline" is not an edge case; it's Tuesday.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Attempt 1: just serve the cache harder.&lt;/strong&gt; Persist the day cache to disk, fetch a longer horizon. This helps (and is a good idea anyway), but it's not synthesis — it can never answer beyond the last fetched day, and it does nothing for a station you never fetched. A cache is a memory, not a model.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2: bundle harmonic constituents for every gate.&lt;/strong&gt; This is the obvious move: tide clocks have synthesized predictions from harmonic constituents for a century, entirely offline. It works — for exactly half the cruising ground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NOAA (US) current constituents:  public domain, freely redistributable ✓
CHS (Canada) current constituents: not redistributable ✗
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CHS harmonic constants are licensed non-commercial, non-redistributable, and not-for-navigation — you cannot ship them in an MIT-licensed npm package. And the Salish Sea's strong narrows — Seymour, Dodd, Gillard, Dent, Arran, Active, Porlier — are all CHS waters. The licensing wall lands precisely on the stations that matter most.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3: WebTide.&lt;/strong&gt; DFO's WebTide model has Salish-wide current constituents, but it's Crown copyright (redistribution needs written permission) and carries only 8 constituents with no shallow-water overtides — and overtides are exactly what constricted channels need (more on that below).&lt;/p&gt;

&lt;p&gt;So v1 bundles what's legal: nine public-domain NOAA current stations covering the US Salish passes — Deception Pass, Rosario Strait, Admiralty Inlet, San Juan Channel, Haro Strait (two bins), Guemes Channel (two bins), and Boundary Pass. The Canadian rapids stay API-only, and the plugin says so out loud rather than pretending otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A tide-height engine, repurposed for currents
&lt;/h3&gt;

&lt;p&gt;There was no currents-capable harmonic synthesis library on npm. But &lt;a href="https://github.com/neaps/neaps" rel="noopener noreferrer"&gt;Neaps&lt;/a&gt; (&lt;code&gt;@neaps/tide-predictor&lt;/code&gt;, MIT) does the hard part — the astronomical argument and node-factor machinery behind summing constituents — for tide &lt;em&gt;heights&lt;/em&gt;. The trick: Neaps doesn't care what the scalar is. Feed it amplitudes in &lt;strong&gt;knots&lt;/strong&gt; instead of meters and its "water level" is the &lt;strong&gt;signed major-axis current velocity&lt;/strong&gt;. Positive level = flood, negative = ebb, zero crossing = slack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/sources/harmonic.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;synthesizeEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HarmonicStation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&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;predictor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createTidePredictor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constituents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;amplitude&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;amplitudeKn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;phase&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phaseDeg&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Flood/ebb peaks come straight from the extremes predictor.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;extremes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;predictor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExtremesPrediction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// ponytail: assumes mean-zero constituents (no Z0/DC-offset term) — a level-curve max&lt;/span&gt;
  &lt;span class="c1"&gt;// is a positive (flood) peak; adding a Z0 constituent would shift the baseline and could flip labels.&lt;/span&gt;
  &lt;span class="kd"&gt;const&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;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;extremes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;eventFromParts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&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="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;high&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;flood&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;ebb&lt;/span&gt;&lt;span class="dl"&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Slack = sign change on a 1-minute timeline; linear-interpolate the crossing.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;predictor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTimelinePrediction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;timeFidelity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;60&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="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;lt;&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&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;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&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="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&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="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;frac&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;level&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;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;frac&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="nx"&gt;events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eventFromParts&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="nx"&gt;t&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slack&lt;/span&gt;&lt;span class="dl"&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="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;events&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="nx"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;y&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;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;localeCompare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;y&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;utc&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;events&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;"High tide" becomes a flood peak, "low tide" an ebb peak, and the output is the same &lt;code&gt;CurrentEvent[]&lt;/code&gt; (slack/flood/ebb) shape the live CHS/NOAA sources emit — a drop-in third source, no restructuring downstream.&lt;/p&gt;

&lt;p&gt;Note the &lt;code&gt;ponytail:&lt;/code&gt; comment: classifying an extremum as flood &lt;em&gt;because Neaps called it a high&lt;/em&gt; only works if the constituent sum is mean-zero. There's no &lt;code&gt;Z0&lt;/code&gt; (mean flow) term in the bundled data, so the baseline sits at zero and the labels hold. Add a mean-flow offset — say, a channel with persistent river outflow — and a weak "flood" maximum can sit entirely below zero: still ebbing, just less. The assumption is documented where it lives, because it's the first thing a future constituent refresh could silently break.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bundling the constituents
&lt;/h3&gt;

&lt;p&gt;Constituents are fetched at build time from NOAA CO-OPS &lt;code&gt;mdapi&lt;/code&gt; &lt;code&gt;harcon&lt;/code&gt; metadata, converted, and committed — never fetched at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// scripts/refresh-constituents.ts (excerpt)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CM_S_PER_KNOT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;51.4444&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;constituents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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;NEAPS_KNOWN&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constituentName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;majorAmplitude&lt;/span&gt;&lt;span class="p"&gt;)&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;constituentName&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;amplitudeKn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;majorAmplitude&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;CM_S_PER_KNOT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// cm/s -&amp;gt; knots&lt;/span&gt;
    &lt;span class="na"&gt;phaseDeg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;majorPhaseGMT&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;                     &lt;span class="c1"&gt;// Greenwich phase, degrees&lt;/span&gt;
  &lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three gotchas live in those six lines:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Units.&lt;/strong&gt; NOAA &lt;code&gt;harcon&lt;/code&gt; major-axis amplitudes are &lt;strong&gt;cm/s&lt;/strong&gt;; Neaps just sums whatever you hand it. Divide by 51.4444 once at generation time and every downstream &lt;code&gt;level&lt;/code&gt; is already knots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phase reference.&lt;/strong&gt; Store &lt;code&gt;majorPhaseGMT&lt;/code&gt; (Greenwich phase, degrees). Neaps evaluates in UTC, so local-phase constants would shift every event by the timezone offset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent drops.&lt;/strong&gt; A constituent name Neaps doesn't know is &lt;strong&gt;silently ignored&lt;/strong&gt; — no error, just a subtly wrong prediction. The refresh script filters against an explicit &lt;code&gt;NEAPS_KNOWN&lt;/code&gt; set so anything unexpected is excluded deliberately at generation time, not accidentally at runtime.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;NOAA's own disclaimer applies and is carried in the data file's provenance header: the data is public domain, and predictions derived from it are &lt;strong&gt;unofficial&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Aligning the horizon with the live path
&lt;/h3&gt;

&lt;p&gt;The live path fetches per UTC day. The harmonic path must synthesize the &lt;em&gt;same&lt;/em&gt; window, or the two series cover different spans and both fallback handoff and discrepancy comparison get skewed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Synthesize the whole horizon in one pass, aligned to UTC-day boundaries so the&lt;/span&gt;
&lt;span class="c1"&gt;// event window matches the live path (fetch.ts iterates UTC days from the same start).&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;synthesizeHorizon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HarmonicStation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;horizonDays&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;HarmonicDayData&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;base&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;UTC&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCFullYear&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCMonth&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUTCDate&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;end&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;horizonDays&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;86400000&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;events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;synthesizeEvents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;end&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;floodDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;floodDir&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;ebbDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ebbDir&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Selection: live wins, fallback declares itself
&lt;/h3&gt;

&lt;p&gt;The refresh loop synthesizes harmonic data for every station with bundled constituents on &lt;em&gt;every&lt;/em&gt; cycle — not just offline ones — then selects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/select.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;selectData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;liveData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DirData&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;harmonicData&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DirData&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;noaa&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;Selected&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;liveData&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;liveData&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="nx"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;live&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;harmonicData&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;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;harmonicData&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;harmonic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;live&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;Every reading that leaves the plugin carries provenance. In the &lt;code&gt;/currents&lt;/code&gt; resource:&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;"stationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUG1701"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deception Pass"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"harmonic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"live"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"unreliableForTransit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"utc"&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-07-16T14:02:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"slack"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"speedKn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"utc"&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-07-16T17:10:00.000Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"kind"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ebb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"speedKn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;5.8&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on the &lt;code&gt;environment.current&lt;/code&gt; delta, as SignalK meta:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;environment.current&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;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="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="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                  &lt;span class="c1"&gt;// 'chs' | 'noaa' | 'harmonic'&lt;/span&gt;
    &lt;span class="na"&gt;live&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                      &lt;span class="c1"&gt;// false when synthesized offline&lt;/span&gt;
    &lt;span class="na"&gt;unreliableForTransit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;station&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;requiresLive&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;true&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;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;object&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;code&gt;requiresLive&lt;/code&gt; is a per-station config flag, pre-set on the constricted passes in the default station list. The design rule: &lt;strong&gt;the plugin states facts; it never suppresses a value and never editorializes.&lt;/strong&gt; A harmonic slack estimate at Deception Pass is still useful for rough planning — deciding whether to look, not when to go. Whatever consumes the data (a chart plotter, an agent, a human reading JSON) decides what to do with &lt;code&gt;unreliableForTransit: true&lt;/code&gt;. Silently degrading — serving a synthesized number in the same shape as an authoritative one — is how someone times a rapids transit on a model that's 25 minutes wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why harmonic predictions fall short at constricted passes
&lt;/h2&gt;

&lt;p&gt;This is the part the README promises and defers, so here it is. Harmonic prediction is a &lt;strong&gt;linear&lt;/strong&gt; model: the current is a sum of fixed sinusoids at astronomical frequencies,&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v(t) = Σᵢ Aᵢ · cos(ωᵢ·t − φᵢ)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with amplitudes and phases fitted to a historical current-meter record. That model is excellent where the tide behaves like a superposition of long waves — open coast, wide straits. A constricted pass breaks each of its assumptions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The flow is hydraulic, not wavelike.&lt;/strong&gt; At a narrows like Seymour (which routinely exceeds 15 knots) the current is driven by the water-level &lt;em&gt;difference&lt;/em&gt; between the basins at each end. The velocity responds roughly as the square root of that head difference, through quadratic bottom/form friction. A square root of a sum of sinusoids is not a sum of sinusoids — the response is nonlinear by construction.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Nonlinearity pumps energy into overtides the bundle truncates.&lt;/strong&gt; The nonlinear terms generate shallow-water constituents — M4, M6, MS4 and friends, at multiples and sums of the astronomical frequencies — which is what makes real rapids curves asymmetric: fast-rising floods, long-tailed ebbs, double-humped peaks. The bundled stations carry 23–29 constituents each (only the ones Neaps knows); the residual distortion the fit couldn't capture doesn't vanish, it lands in the error — concentrated where the curve shape matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The error concentrates at slack — the only moment you care about.&lt;/strong&gt; At peak flow, a 10% amplitude error changes nothing about your decision; you weren't going through Dent Rapids at max ebb anyway. Near slack, the head difference across the pass is small, so &lt;em&gt;everything the constituents don't model&lt;/em&gt; — wind setup, atmospheric pressure gradients, river discharge in freshet, the truncated overtides — shifts the zero crossing directly. The steeper, shorter slack window at a strong narrows means a modest phase error translates to arriving with real current already running. That's the mechanism behind "off by tens of minutes," and it's why the flag is named &lt;code&gt;unreliableForTransit&lt;/code&gt; and not something politer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The fit itself is weaker.&lt;/strong&gt; Height constituents come from decades-long water-level records at permanent gauges. Current constituents come from comparatively short current-meter deployments in turbulent, stratified water, resolved per depth bin. The inputs are noisier before the model's structural limits even apply.&lt;/p&gt;

&lt;p&gt;None of this makes the harmonic fallback useless — Boundary Pass under harmonic synthesis is a perfectly serviceable planning baseline. It makes the fallback &lt;em&gt;conditionally&lt;/em&gt; trustworthy, and the condition is knowable per station in advance. That's exactly what a config flag is for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure the fallback while you still have ground truth
&lt;/h2&gt;

&lt;p&gt;The claim above — "off by tens of minutes" — deserves numbers, per station. The trick is that the best time to measure an offline fallback is when you're online: every poll cycle where a station has &lt;em&gt;both&lt;/em&gt; live and harmonic data, the plugin diffs them and appends a line to a local JSONL log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/compare.ts — deltas are signed (harmonic − live):&lt;/span&gt;
&lt;span class="c1"&gt;// +slackDeltaMin = harmonic slack is later&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;computeDiscrepancy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;stationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;harmonic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;CurrentEvent&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Discrepancy&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;ls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextSlackAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;nextSlackAfter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;harmonic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&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;lp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;peakRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;peakRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;harmonic&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;utc&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;now&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="nx"&gt;stationId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;slackDeltaMin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ls&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hs&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;hs&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;ls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;peakRateDeltaKn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;hp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;lp&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;SignalK dataDir&amp;gt;/signalk-currents-discrepancies.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row per station per poll: how far off the harmonic next-slack estimate is (signed minutes) and how far off the peak rate is (signed knots). Local file only — nothing is phoned home. The write is best-effort and swallowed on failure, because diagnostics must never take down the data path they're diagnosing.&lt;/p&gt;

&lt;p&gt;The design intent: when someone eventually asks "how wrong is the fallback at &lt;em&gt;this&lt;/em&gt; station?", the answer is a &lt;code&gt;jq&lt;/code&gt; one-liner over months of accumulated rows, not a shrug. The follow-up post with a season of measured deltas is the plan; this post is the mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NOAA &lt;code&gt;harcon&lt;/code&gt; amplitudes are cm/s&lt;/strong&gt; — convert to knots (÷ 51.4444) at generation time, not runtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Neaps silently drops constituent names it doesn't know.&lt;/strong&gt; Filter against an explicit known set when generating the bundle, or you'll ship a prediction missing constituents with no error anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flood/ebb labels assume mean-zero constituents.&lt;/strong&gt; A &lt;code&gt;Z0&lt;/code&gt;/mean-flow term would shift the baseline and can flip extremum labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Align synthesis windows to UTC-day boundaries&lt;/strong&gt; to match a per-day live fetch path, or fallback handoffs jump.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You may not be able to redistribute your local constituents.&lt;/strong&gt; NOAA is public domain; CHS is not. Check before bundling anything Canadian — the licensing boundary decides your offline coverage map, not your code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provenance is part of the data model, not a log line.&lt;/strong&gt; &lt;code&gt;source&lt;/code&gt; / &lt;code&gt;live&lt;/code&gt; / &lt;code&gt;unreliableForTransit&lt;/code&gt; ride on the resource payload &lt;em&gt;and&lt;/em&gt; the SignalK meta, so every consumer sees them — not just whoever reads the server log.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;This plugin is part of the open-source navigation stack for an all-electric charter catamaran that will spend its life transiting exactly these passes — which is why the fallback that keeps currents flowing offline also has to be honest about where it can't be trusted. Code, bundled constituents, and the refresh script: &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;github.com/sailingnaturali/signalk-currents&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-06-marine-weather-mcp-buoy-ground-truth-ndbc-spec-swell-wind-waves%20"&gt;why generic weather MCPs fail for marine navigation&lt;/a&gt; — the same verify-against-reality instinct, with NDBC buoys as ground truth — and &lt;a href="https://dev.to%20post_url%202026-06-21-llm-agent-confabulation-inventing-infrastructure-and-data-provenance%20"&gt;LLM agents confabulating data provenance&lt;/a&gt;, the failure mode provenance fields exist to prevent.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>marine</category>
      <category>currents</category>
      <category>tides</category>
    </item>
    <item>
      <title>Running OpenClaw on a Raspberry Pi alongside SignalK</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Wed, 29 Jul 2026 12:50:26 +0000</pubDate>
      <link>https://dev.to/clarkbw--/running-openclaw-on-a-raspberry-pi-alongside-signalk-53ob</link>
      <guid>https://dev.to/clarkbw--/running-openclaw-on-a-raspberry-pi-alongside-signalk-53ob</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Running &lt;a href="https://docs.openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw&lt;/a&gt; — a self-hosted, multi-channel agent gateway — on a Raspberry Pi 5 next to &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt;, so you can DM the boat over Telegram and get live vessel readings back. The traps that cost me time are up front — the &lt;code&gt;gateway.mode&lt;/code&gt; start-block, SI-unit conversion, no history without InfluxDB, and a Chesterton's-fence with the &lt;code&gt;ollama&lt;/code&gt; plugin — then the walkthrough, then what I found tuning the standing prompt down. Jump to the gotchas.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's no existing writeup for this combination anywhere I could find, so here's the whole thing: what to know before you start, the setup that actually works, and the token-cost tuning that made it cheap enough to leave running.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build
&lt;/h2&gt;

&lt;p&gt;OpenClaw is a Node/TypeScript daemon you run on your own hardware; you talk to an LLM-backed agent over chat channels (Telegram, Slack, etc.). SignalK is the open marine data server that normalises NMEA 2000/0183 into one JSON model. Put them on the same Pi and you get a boat assistant you DM from your phone that can read live vessel state.&lt;/p&gt;

&lt;p&gt;They share the box but stay in their own lanes. OpenClaw reads SignalK over HTTP — it does not run inside the marine stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Raspberry Pi 5 (8 GB)
  ┌─────────────────────────────────────────────┐
  │  SignalK (Docker)          OpenClaw (native) │
  │  localhost:3000  ◀── HTTP ──  gateway daemon │
  │  + InfluxDB/Grafana           (systemd user) │
  └─────────────────────────────────────────────┘
            ▲                          ▲
       NMEA 2000/0183            Telegram DM
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SignalK is typically already running in Docker on &lt;code&gt;localhost:3000&lt;/code&gt; next to InfluxDB and Grafana. OpenClaw installs natively alongside it. No MCP server, no plugin inside SignalK — the agent just curls the SignalK REST API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas first
&lt;/h2&gt;

&lt;p&gt;These are the ones that don't show up until you hit them.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The gateway won't start without &lt;code&gt;gateway.mode&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;If the config is missing &lt;code&gt;gateway.mode&lt;/code&gt;, the daemon refuses to start:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Gateway start blocked: set gateway.mode=local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Onboarding sets this for you. But if you hand-edit the config and drop the key — or a tool rewrites it — OpenClaw treats the missing key as suspicious/clobbered config and blocks the start rather than guessing. The fix is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw config &lt;span class="nb"&gt;set &lt;/span&gt;gateway.mode &lt;span class="nb"&gt;local&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Or re-run onboarding with &lt;code&gt;openclaw onboard --mode local&lt;/code&gt;.) See the &lt;a href="https://docs.openclaw.ai/gateway/troubleshooting" rel="noopener noreferrer"&gt;gateway troubleshooting docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. SignalK values are SI — the agent must convert
&lt;/h3&gt;

&lt;p&gt;SignalK stores everything in SI units. A raw read &lt;em&gt;looks&lt;/em&gt; fine and is silently wrong for a human:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;speed      → m/s     (× 1.94384 for knots)
angles     → radians (× 57.2958 for degrees)
temperature→ Kelvin  (− 273.15 for °C)
depth      → metres
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;environment.wind.speedApparent&lt;/code&gt; comes back as &lt;code&gt;8.5&lt;/code&gt; — that's 8.5 &lt;strong&gt;m/s&lt;/strong&gt;, i.e. ~16.5 knots, not 8.5 knots. If you don't tell the agent to convert, it will happily report the SI number as if it were the human unit. Bake the conversions into the agent's instructions (or a skill), and spell out the target units.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. No history without InfluxDB
&lt;/h3&gt;

&lt;p&gt;SignalK's REST API serves live values fine anonymously. But the history endpoint returns a 404 unless the InfluxDB-backed history plugin is running:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v2/history/values?paths&lt;span class="o"&gt;=&lt;/span&gt;electrical.batteries.house.stateOfCharge
&lt;span class="c"&gt;# 404 unless signalk-to-influxdb (+ history API) is installed and running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without it the agent is live-only — it answers "what's the depth &lt;em&gt;now&lt;/em&gt;," not "what was the minimum overnight." If you want the agent to reason over trends, stand up the history plugin first; otherwise scope its instructions to present-tense questions.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;code&gt;jq&lt;/code&gt; isn't installed by default
&lt;/h3&gt;

&lt;p&gt;Raspberry Pi OS ships without &lt;code&gt;jq&lt;/code&gt;. If your agent instructions pipe curl through &lt;code&gt;jq&lt;/code&gt;, they'll fail on a fresh box:&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v1/api/vessels/self/environment/depth/belowTransducer | jq .value
bash: jq: &lt;span class="nb"&gt;command &lt;/span&gt;not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two options: &lt;code&gt;sudo apt install jq&lt;/code&gt;, or just don't — hand the agent the raw JSON and let it parse. An LLM reads &lt;code&gt;{ "value": 4.2, "timestamp": "…" }&lt;/code&gt; without help. I went with raw JSON; one fewer moving part on the boat.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Chesterton's fence — the &lt;code&gt;ollama&lt;/code&gt; plugin does more than you think
&lt;/h3&gt;

&lt;p&gt;I disabled the &lt;code&gt;ollama&lt;/code&gt; plugin assuming it was only a (dead, for me) web-search provider. Later a local Ollama model failed to load:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No API provider registered for api: ollama
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ollama&lt;/code&gt; plugin &lt;em&gt;also&lt;/em&gt; registers the Ollama API runtime — the thing that lets you use Ollama as a &lt;strong&gt;model backend&lt;/strong&gt;, not just a search tool. Disabling it pulled the backend out from under the model. The lesson is the old one: don't disable a plugin until you know &lt;em&gt;everything&lt;/em&gt; it provides. (Re-enabling it fixed the load.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The walkthrough
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Install (native)
&lt;/h3&gt;

&lt;p&gt;OpenClaw supports Docker, but on a Pi that's already running SignalK's own Docker stack I installed OpenClaw natively and let it manage its own daemon — it keeps the marine containers isolated and the agent's lifecycle separate. The installer targets Node 24 by default (the supported range is Node 22.22.3+, 24.15+, or 25.9+) and handles the runtime for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://openclaw.ai/install.sh | bash
&lt;span class="c"&gt;# or: npm install -g openclaw@latest&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the guided setup — it walks you through auth (an Anthropic API key, or an OAuth login), model choice, and the daemon:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw onboard
openclaw onboard &lt;span class="nt"&gt;--install-daemon&lt;/span&gt;   &lt;span class="c"&gt;# installs a systemd user service&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a headless Pi, the user service dies at logout unless you enable lingering:&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="nb"&gt;sudo &lt;/span&gt;loginctl enable-linger &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$USER&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the piece that makes it survive a reboot. See the &lt;a href="https://docs.openclaw.ai/install" rel="noopener noreferrer"&gt;install docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Telegram
&lt;/h3&gt;

&lt;p&gt;Make a bot with &lt;a href="https://t.me/BotFather" rel="noopener noreferrer"&gt;@BotFather&lt;/a&gt;, then point OpenClaw at the token — either in config or via env:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw config &lt;span class="nb"&gt;set &lt;/span&gt;channels.telegram.botToken &lt;span class="s2"&gt;"&amp;lt;BOT_TOKEN&amp;gt;"&lt;/span&gt;
&lt;span class="c"&gt;# or export TELEGRAM_BOT_TOKEN=&amp;lt;BOT_TOKEN&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep DMs gated with pairing so a stranger who finds the bot can't drive your boat:&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;// channels.telegram&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;"dmPolicy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pairing"&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;DM the bot, then approve the code it gives you (codes expire after an hour):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openclaw pairing approve telegram &amp;lt;CODE&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Details in the &lt;a href="https://docs.openclaw.ai/channels/telegram" rel="noopener noreferrer"&gt;Telegram channel docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading SignalK over HTTP — no MCP needed
&lt;/h3&gt;

&lt;p&gt;SignalK's REST API is anonymous when &lt;code&gt;allow_readonly&lt;/code&gt; is on, so you don't need a custom SignalK MCP server for reads. Give the agent the &lt;code&gt;exec&lt;/code&gt; tool and let it curl. A SignalK dotted path maps straight to URL slashes:&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;# environment.depth.belowTransducer  →  .../environment/depth/belowTransducer&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v1/api/vessels/self/environment/depth/belowTransducer
&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;"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;"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-07-27T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"meta"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"units"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"m"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grab a whole subtree in one call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:3000/signalk/v1/api/vessels/self/electrical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The one prerequisite: &lt;code&gt;exec&lt;/code&gt; only exists in certain tool profiles — which is the first tuning finding below.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning finding 1 — skill vs the agent file
&lt;/h2&gt;

&lt;p&gt;OpenClaw injects workspace files into context every turn: a &lt;code&gt;SOUL.md&lt;/code&gt; persona and an &lt;code&gt;AGENTS.md&lt;/code&gt; instruction/memory file. Skills live at &lt;code&gt;workspace/skills/&amp;lt;name&amp;gt;/SKILL.md&lt;/code&gt; — the skill's name+description is advertised every turn, but its body loads on demand when the skill fires.&lt;/p&gt;

&lt;p&gt;So where do the SignalK-reading instructions belong — always-on in &lt;code&gt;AGENTS.md&lt;/code&gt;, or in a &lt;code&gt;SKILL.md&lt;/code&gt;? Intuition says the skill: pay for the body only when you use it. I measured prompt tokens per turn both ways:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Turn&lt;/th&gt;
&lt;th&gt;in AGENTS.md (always-on)&lt;/th&gt;
&lt;th&gt;as a skill (on-demand)&lt;/th&gt;
&lt;th&gt;Δ&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No-tool question&lt;/td&gt;
&lt;td&gt;13,808&lt;/td&gt;
&lt;td&gt;13,380&lt;/td&gt;
&lt;td&gt;−428 (skill wins)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single boat read&lt;/td&gt;
&lt;td&gt;14,070&lt;/td&gt;
&lt;td&gt;14,427&lt;/td&gt;
&lt;td&gt;+357 (skill loses)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi boat read&lt;/td&gt;
&lt;td&gt;16,134&lt;/td&gt;
&lt;td&gt;16,628&lt;/td&gt;
&lt;td&gt;+494 (skill loses)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Counterintuitive but consistent: &lt;strong&gt;for an agent whose whole job is this domain, the always-on agent file is slightly cheaper.&lt;/strong&gt; The skill's &lt;em&gt;description&lt;/em&gt; still sits in context every turn (alongside every other skill's), and loading its &lt;em&gt;body&lt;/em&gt; on a domain turn costs more than the tiny always-on snippet it replaced. The skill only wins when most turns &lt;em&gt;don't&lt;/em&gt; touch the domain — and for a boat agent, most turns do.&lt;/p&gt;

&lt;p&gt;(This is the same always-on-vs-conditional split we hit on a different framework — see the related post below.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning finding 2 — cutting the ~14k standing prompt
&lt;/h2&gt;

&lt;p&gt;Every turn, even a no-tool question, carries a ~14k-token standing prompt: base preamble + tool schemas + workspace files. That's structural to a general-purpose gateway. Three things I tried to shrink it, in order of what actually worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  What worked — the tool profile
&lt;/h3&gt;

&lt;p&gt;OpenClaw gates tools by &lt;code&gt;tools.profile&lt;/code&gt;: &lt;code&gt;minimal&lt;/code&gt; (just &lt;code&gt;session_status&lt;/code&gt;), &lt;code&gt;coding&lt;/code&gt; (filesystem + runtime/&lt;code&gt;exec&lt;/code&gt; + web + more), &lt;code&gt;messaging&lt;/code&gt;, and &lt;code&gt;full&lt;/code&gt;. &lt;strong&gt;Only &lt;code&gt;coding&lt;/code&gt; grants both &lt;code&gt;exec&lt;/code&gt; and filesystem&lt;/strong&gt; — which is why the SignalK-over-curl approach needs it.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;coding&lt;/code&gt; also drags in a pile of dev-tool schemas a boat agent never calls. If all you need is shell + file read/write, drop to &lt;code&gt;minimal&lt;/code&gt; and additively re-allow just those two groups:&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;// tools&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;"profile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"minimal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"alsoAllow"&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;"group:runtime"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"group:fs"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;group:runtime&lt;/code&gt; is &lt;code&gt;exec&lt;/code&gt;/process; &lt;code&gt;group:fs&lt;/code&gt; is read/write/edit. That cut &lt;strong&gt;~5,000 tokens/turn (~25%)&lt;/strong&gt; — pure schema for tools the agent never uses — with zero functional loss. See the &lt;a href="https://docs.openclaw.ai/gateway/config-tools" rel="noopener noreferrer"&gt;tool-profile docs&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  What did NOT work — disabling plugins
&lt;/h3&gt;

&lt;p&gt;I assumed the loaded plugins (browser, canvas, phone-control, talk-voice…) were bloating the prompt. Disabling four of them saved &lt;strong&gt;~270 tokens&lt;/strong&gt; — nothing.&lt;/p&gt;

&lt;p&gt;Why: tool schemas are gated by tool &lt;em&gt;policy&lt;/em&gt;, not plugin &lt;em&gt;state&lt;/em&gt;. Once the &lt;code&gt;minimal&lt;/code&gt; profile excludes a tool, its schema is already gone whether or not the plugin loads. So:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Disabling a plugin does not save prompt tokens if the profile already denies its tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(Still disable unused plugins — for the Pi's RAM, CPU, and boot time. Just don't expect a token win.)&lt;/p&gt;

&lt;h3&gt;
  
  
  The real lever — caching, not token count
&lt;/h3&gt;

&lt;p&gt;The standing prompt bills at full price only on a &lt;em&gt;cold&lt;/em&gt; turn. OpenClaw auto-injects Anthropic &lt;code&gt;cache_control&lt;/code&gt; on the stable prefix, so the ~14k reads from cache at ~10% on warm turns. I watched it in one session: the cold turn &lt;em&gt;wrote&lt;/em&gt; ~14k tokens to cache; the next same-session turn &lt;em&gt;read&lt;/em&gt; ~14k from cache and &lt;em&gt;wrote&lt;/em&gt; only ~150.&lt;/p&gt;

&lt;p&gt;For bursty use — messages minutes apart, which is exactly how you DM a boat — the default 5-minute cache TTL keeps cold-starting. Bump it:&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;// long cache retention → 1h TTL&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;"cacheRetention"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"long"&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;One version caveat: OpenClaw is careful to order the stable context files ahead of the churny heartbeat file so heartbeat updates don't bust the cached prefix — but older builds had a cache-busting bug where a per-message value landed &lt;em&gt;inside&lt;/em&gt; the cached block and re-wrote the whole prefix every turn. If your "cached" reads look like full-price writes, check your version. See the &lt;a href="https://docs.openclaw.ai/reference/prompt-caching" rel="noopener noreferrer"&gt;prompt-caching docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus — a watch that stays quiet
&lt;/h2&gt;

&lt;p&gt;Once the agent can read SignalK, a natural next step is a scheduled check that only speaks when something's wrong. OpenClaw's cron supports a deterministic &lt;code&gt;--trigger-script&lt;/code&gt; gate: the script inspects SignalK (any non-&lt;code&gt;normal&lt;/code&gt; notification, or a battery/depth/tank threshold), returns &lt;code&gt;{ fire, message?, state? }&lt;/code&gt;, and the agent only composes a Telegram heads-up when &lt;code&gt;fire&lt;/code&gt; is true — de-duped on &lt;code&gt;state&lt;/code&gt; so it alerts on &lt;em&gt;change&lt;/em&gt;, not every tick. A watch that's silent when all's well. That's its own post; the &lt;a href="https://docs.openclaw.ai/automation/cron-jobs" rel="noopener noreferrer"&gt;cron docs&lt;/a&gt; have the shape.&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 "what's our depth?" should be a Telegram DM away and the standing prompt cheap enough to leave running on a Pi. If you're standing this up yourself, the two references worth bookmarking are the &lt;a href="https://docs.openclaw.ai" rel="noopener noreferrer"&gt;OpenClaw docs&lt;/a&gt; and &lt;a href="https://signalk.org" rel="noopener noreferrer"&gt;signalk.org&lt;/a&gt; — the rest is the six lines of gotchas above.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-11-agent-skill-body-vs-base-system-prompt-always-on-conditional-deploy%20"&gt;Why your agent ignores its skill body but obeys the system prompt&lt;/a&gt; — the always-on-vs-conditional split behind tuning finding 1; and &lt;a href="https://dev.to%20post_url%202026-06-06-signalk-mcp-named-tools-vs-execute-code-token-efficiency-voice-agent%20"&gt;Why we kept named MCP tools despite a 96% token saving&lt;/a&gt; — the tokens-vs-reliability tradeoff for boat agents.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>openclaw</category>
      <category>signalk</category>
      <category>raspberrypi</category>
      <category>ai</category>
    </item>
    <item>
      <title>httpx async requests to a .local hostname time out on macOS</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 28 Jul 2026 16:57:19 +0000</pubDate>
      <link>https://dev.to/clarkbw--/httpx-async-requests-to-a-local-hostname-time-out-on-macos-50a8</link>
      <guid>https://dev.to/clarkbw--/httpx-async-requests-to-a-local-hostname-time-out-on-macos-50a8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — On macOS, &lt;code&gt;httpx.AsyncClient&lt;/code&gt; requests to a &lt;code&gt;.local&lt;/code&gt; (mDNS/Bonjour)&lt;br&gt;
hostname raise &lt;code&gt;httpx.ConnectTimeout&lt;/code&gt; even though the host is up and &lt;code&gt;curl&lt;/code&gt;&lt;br&gt;
works instantly. &lt;code&gt;getaddrinfo&lt;/code&gt; puts an IPv6 candidate first, and the async&lt;br&gt;
connect burns the whole connect timeout on that unroutable path before IPv4&lt;br&gt;
gets a turn. Resolve the &lt;code&gt;.local&lt;/code&gt; host to IPv4 yourself, once, at the URL&lt;br&gt;
boundary — the system resolver handles mDNS fine — and rewrite the URL.&lt;br&gt;
Jump to the fix.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our boat's AI agent talks to a &lt;a href="https://signalk.org/" rel="noopener noreferrer"&gt;SignalK&lt;/a&gt; server on a&lt;br&gt;
Raspberry Pi through a fleet of MCP servers — vessel data, logbook, tidal&lt;br&gt;
currents, weather. All of them are thin Python services that reach the Pi over&lt;br&gt;
HTTP with &lt;code&gt;httpx.AsyncClient&lt;/code&gt;, and all of them address it by its mDNS name,&lt;br&gt;
&lt;code&gt;boat-server.local&lt;/code&gt;. One day, running the fleet from a Mac, every single tool&lt;br&gt;
call timed out. Not one server — all of them, silently, the same way.&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;The client code is as plain as HTTP gets:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&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;client&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://boat-server.local:3000/signalk/v1/api/vessels/self&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;p&gt;And every call dies 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="n"&gt;File&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.../httpx/_transports/default.py&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="mi"&gt;101&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;map_httpcore_exceptions&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nf"&gt;mapped_exc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;exc&lt;/span&gt;
&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectTimeout&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five seconds per request, every request, &lt;code&gt;httpx.ConnectTimeout&lt;/code&gt; at the end of&lt;br&gt;
it. From the agent's point of view the whole tool surface is just... gone. It&lt;br&gt;
answers questions with "I couldn't reach the vessel data server" while the&lt;br&gt;
server sits there, healthy, twenty feet away.&lt;/p&gt;

&lt;p&gt;The part that sends you down the wrong path: &lt;strong&gt;everything else can reach it.&lt;/strong&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://boat-server.local:3000/signalk/v1/api/vessels/self | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; 60
&lt;span class="go"&gt;{"name":"Naturali","mmsi":"368...
&lt;/span&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;ping boat-server.local
&lt;span class="go"&gt;64 bytes from 192.168.1.20: icmp_seq=0 ttl=64 time=4.2 ms
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instant. So the server is fine, mDNS resolution is fine, the network is fine —&lt;br&gt;
only the async Python path fails.&lt;/p&gt;
&lt;h2&gt;
  
  
  Diagnosis
&lt;/h2&gt;

&lt;p&gt;Ask the resolver what it actually returns for the 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;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import socket; &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="go"&gt;    [print(ai[0].name, ai[4][0]) for ai in \
     socket.getaddrinfo('boat-server.local', 3000, type=socket.SOCK_STREAM)]"
AF_INET6 fe80::dea6:32ff:fe12:3456
AF_INET  192.168.1.20
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There it is. On macOS, &lt;code&gt;getaddrinfo&lt;/code&gt; for the &lt;code&gt;.local&lt;/code&gt; name returns &lt;strong&gt;an IPv6&lt;br&gt;
candidate first&lt;/strong&gt; — here a link-local &lt;code&gt;fe80::&lt;/code&gt; address — and the IPv4 address&lt;br&gt;
second. The IPv6 candidate is not usefully routable from the client (a&lt;br&gt;
link-local address needs a scope/zone ID to be connectable, and the resolved&lt;br&gt;
candidate doesn't carry a working one), so connecting to it goes nowhere: no&lt;br&gt;
refusal, no reset, just silence until the timeout.&lt;/p&gt;

&lt;p&gt;Modern clients are supposed to shrug this off. That's the whole point of&lt;br&gt;
&lt;a href="https://datatracker.ietf.org/doc/html/rfc8305" rel="noopener noreferrer"&gt;Happy Eyeballs (RFC 8305)&lt;/a&gt;:&lt;br&gt;
start with the first address family, and if it hasn't connected within a couple&lt;br&gt;
hundred milliseconds, race the other one in parallel. &lt;code&gt;curl&lt;/code&gt; does exactly this,&lt;br&gt;
which is why it never blinks. The async Python stack is &lt;em&gt;supposed&lt;/em&gt; to do it too&lt;br&gt;
— anyio &lt;a href="https://github.com/agronholm/anyio/issues/69" rel="noopener noreferrer"&gt;implements Happy Eyeballs in &lt;code&gt;connect_tcp()&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
with a 250 ms stagger.&lt;/p&gt;

&lt;p&gt;What we observed instead: the async connect sat on the dead IPv6 attempt for&lt;br&gt;
the &lt;strong&gt;full connect timeout&lt;/strong&gt; before IPv4 ever got a useful turn — so with a 5 s&lt;br&gt;
timeout, every request was &lt;code&gt;ConnectTimeout&lt;/code&gt;, 100% reproducible. Whatever the&lt;br&gt;
stagger did under the hood, the practical behaviour on macOS with a &lt;code&gt;.local&lt;/code&gt;&lt;br&gt;
name was "wait out IPv6, then die."&lt;/p&gt;

&lt;p&gt;And it hit &lt;strong&gt;every MCP server in the fleet at once&lt;/strong&gt;, because they all shared&lt;br&gt;
the same idiom: &lt;code&gt;SIGNALK_URL=http://boat-server.local:3000&lt;/code&gt; in the environment,&lt;br&gt;
&lt;code&gt;httpx.AsyncClient&lt;/code&gt; in the client. We found it because an agent benchmark run&lt;br&gt;
suddenly scored terribly — the model was doing everything right and the tools&lt;br&gt;
were timing out underneath it.&lt;/p&gt;
&lt;h2&gt;
  
  
  What we tried (and why it failed)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Blame the server.&lt;/strong&gt; Restarted SignalK, checked its logs, hit the REST API&lt;br&gt;
from three other machines. All fine — of course it was; &lt;code&gt;curl&lt;/code&gt; from the same&lt;br&gt;
Mac already proved that. Time spent anyway: too much.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Raise the timeout.&lt;/strong&gt;&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;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;30.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This just moves the pain. The connect still burns the IPv6 attempt first, so&lt;br&gt;
every cold request eats seconds before doing anything useful — and this stack&lt;br&gt;
answers &lt;em&gt;voice&lt;/em&gt; queries with a hard latency budget of a few seconds total. A&lt;br&gt;
"fix" that spends the entire budget waiting on a dead address family is a&lt;br&gt;
failure with extra steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hardcode the IP.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SIGNALK_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://192.168.1.20:3000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works instantly — which confirms the diagnosis: connect to the IPv4 address and&lt;br&gt;
there's nothing wrong at all. But the Pi's address comes from DHCP, and the&lt;br&gt;
entire reason the config says &lt;code&gt;boat-server.local&lt;/code&gt; is so nobody maintains an IP&lt;br&gt;
address by hand. Hardcoding it trades a bug for a chore.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Force IPv4 in the transport.&lt;/strong&gt; httpx has a&lt;br&gt;
&lt;a href="https://www.python-httpx.org/advanced/transports/" rel="noopener noreferrer"&gt;documented knob&lt;/a&gt; for&lt;br&gt;
exactly 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="n"&gt;transport&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncHTTPTransport&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;transport&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Binding the local side to &lt;code&gt;0.0.0.0&lt;/code&gt; restricts connections to IPv4, and it does&lt;br&gt;
work. But it has to be threaded through &lt;strong&gt;every&lt;/strong&gt; &lt;code&gt;AsyncClient&lt;/code&gt; construction&lt;br&gt;
site, in every service — including ones where the client is built deep inside a&lt;br&gt;
helper. And it turns off IPv6 for &lt;em&gt;all&lt;/em&gt; destinations that client touches, not&lt;br&gt;
just the one broken &lt;code&gt;.local&lt;/code&gt; name. We wanted the fix at the one place all the&lt;br&gt;
services already share: the URL.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Resolve the &lt;code&gt;.local&lt;/code&gt; host to its IPv4 address &lt;strong&gt;once, at the URL boundary&lt;/strong&gt;,&lt;br&gt;
and hand httpx a URL with the IP already in it. The synchronous system resolver&lt;br&gt;
handles mDNS perfectly well — the problem was never resolution, it was which&lt;br&gt;
candidate the async connect tried first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;urllib.parse&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;urlsplit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urlunsplit&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;resolve_local_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Resolve a .local (mDNS) host in base_url to its IPv4 address.

    Non-.local hosts, empty input, and resolution failures are returned
    unchanged so httpx still gets its normal shot.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;urlsplit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hostname&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;host&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.local&lt;/span&gt;&lt;span class="sh"&gt;"&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;base_url&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;infos&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getaddrinfo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AF_INET&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                   &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SOCK_STREAM&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="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gaierror&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;OSError&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;base_url&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;infos&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;base_url&lt;/span&gt;
    &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;infos&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;4&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="n"&gt;netloc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ip&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&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;ip&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;urlunsplit&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;scheme&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;netloc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;parts&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="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="n"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fragment&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it where the URL enters the process:&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;base_url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resolve_local_host&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SIGNALK_URL&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;http://boat-server.local:3000&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing &lt;code&gt;socket.AF_INET&lt;/code&gt; to &lt;code&gt;getaddrinfo&lt;/code&gt; asks for IPv4 candidates only, so&lt;br&gt;
there is no IPv6 address to trip over. Every request connects in milliseconds.&lt;/p&gt;

&lt;p&gt;Two properties made this the right shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a pass-through.&lt;/strong&gt; Non-&lt;code&gt;.local&lt;/code&gt; hosts come back untouched. Resolution
failures come back untouched — httpx gets its normal shot and produces its
normal error, instead of this helper inventing a new failure mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's at the boundary.&lt;/strong&gt; One call where config becomes a client, instead of
a transport flag threaded through every constructor.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  One fix, five repos: when a patch becomes a library
&lt;/h2&gt;

&lt;p&gt;The first version of that function was pasted inline into the SignalK MCP&lt;br&gt;
server, with a docstring and a small test file. Then the logbook server needed&lt;br&gt;
it. Then currents. Then weather. Same commit message four times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fix(client): resolve .local mDNS host to IPv4 (macOS httpx async/IPv6 hang)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy-pasting a fix across a fleet is fine exactly once — it's how you learn the&lt;br&gt;
fix is actually cross-cutting. The fourth paste is the signal: this is a shared&lt;br&gt;
library now. Ours is&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/naturali-mcp-netutil" rel="noopener noreferrer"&gt;&lt;code&gt;naturali-mcp-netutil&lt;/code&gt;&lt;/a&gt;&lt;br&gt;
— one function, stdlib-only, MIT, with the whole &lt;em&gt;why&lt;/em&gt; in the README.&lt;/p&gt;

&lt;p&gt;We deliberately did &lt;strong&gt;not&lt;/strong&gt; publish it to PyPI. Consumers pull it as a&lt;br&gt;
&lt;a href="https://docs.astral.sh/uv/concepts/projects/dependencies/#git" rel="noopener noreferrer"&gt;uv git source&lt;/a&gt;&lt;br&gt;
pinned to a tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;dependencies&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;["naturali-mcp-netutil&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="s"&gt;"]&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;
&lt;span class="nn"&gt;[tool.uv.sources]&lt;/span&gt;
&lt;span class="py"&gt;naturali-mcp-netutil&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;git&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://github.com/sailingnaturali/naturali-mcp-netutil"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="py"&gt;tag&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"v0.1.0"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reasoning: this package has exactly four consumers, all ours. A PyPI&lt;br&gt;
release adds packaging ceremony, a public namespace entry, and an implied&lt;br&gt;
support surface — for a 30-line function nobody outside the fleet should&lt;br&gt;
depend on. The git source gets pinned in each consumer's &lt;code&gt;uv.lock&lt;/code&gt;, so builds&lt;br&gt;
are just as reproducible, and if the helper ever grows a real audience,&lt;br&gt;
publishing it properly later costs nothing. Publish when someone else needs&lt;br&gt;
it, not when you do.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The failure is silent at the layer you're watching.&lt;/strong&gt; An MCP tool that
times out doesn't crash anything — the agent just reports it can't get the
data, which reads like a model problem or a server problem. If every tool
backed by the same host degrades at once, suspect the network path before
either.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"curl works" localizes the bug better than any log.&lt;/strong&gt; Same URL, same
machine, different HTTP stack → the bug lives in the stack, not the server.
That one console line should have saved the first hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;getaddrinfo&lt;/code&gt; order is the whole game.&lt;/strong&gt; Async connect logic believes the
resolver's ordering. When the first candidate is unroutable-but-silent (no
RST, just a black hole), timeouts get charged to whoever is first in line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix outlived the problem.&lt;/strong&gt; We later retired mDNS naming entirely —
the fleet now reaches the boat server by a stable DNS name on a private
tailnet, and &lt;code&gt;.local&lt;/code&gt; no longer appears in any config. The resolver stayed:
because it passes every non-&lt;code&gt;.local&lt;/code&gt; URL through unchanged, it costs nothing,
and it still guards anyone who points a config at a Bonjour name. A fix
that's a pass-through survives its own obsolescence.&lt;/li&gt;
&lt;/ul&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&lt;br&gt;
catamaran — a stack of open-source MCP servers between the agent and the boat's&lt;br&gt;
SignalK data. The resolver and the servers it guards are public:&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/naturali-mcp-netutil" rel="noopener noreferrer"&gt;naturali-mcp-netutil&lt;/a&gt;,&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;signalk-mcp&lt;/a&gt;, and the rest at&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;github.com/sailingnaturali&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-04-launchd-minimal-path-breaks-mcp-servers-uv-command-not-found%20"&gt;launchd's minimal PATH breaks MCP servers: uv command not found&lt;/a&gt; — the other way an environment silently kills an MCP fleet — and &lt;a href="https://dev.to%20post_url%202026-06-23-mcp-tools-not-showing-up-isolate-the-server-before-blaming-the-agent%20"&gt;when MCP tools break, isolate the server before blaming the agent&lt;/a&gt; for the debugging discipline that finds this class of bug.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>httpx</category>
      <category>macos</category>
      <category>mdns</category>
    </item>
    <item>
      <title>Where a received distress call belongs in the SignalK data model</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Sun, 26 Jul 2026 16:04:48 +0000</pubDate>
      <link>https://dev.to/clarkbw--/where-a-received-distress-call-belongs-in-the-signalk-data-model-22oe</link>
      <guid>https://dev.to/clarkbw--/where-a-received-distress-call-belongs-in-the-signalk-data-model-22oe</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — a distress alert heard &lt;em&gt;from another vessel&lt;/em&gt; is not your boat's own alarm state, so don't raise it as plain &lt;code&gt;notifications.*&lt;/code&gt; on self. The convention we landed on (&lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;signalk-dsc&lt;/a&gt; v0.9.0, &lt;a href="https://github.com/sailingnaturali/signalk-ais-distress" rel="noopener noreferrer"&gt;signalk-ais-distress&lt;/a&gt; v0.5.0): raise the self-side alarm under &lt;strong&gt;&lt;code&gt;notifications.received.*&lt;/code&gt;&lt;/strong&gt; ("received about another vessel"), and &lt;em&gt;also&lt;/em&gt; write the state record into the &lt;strong&gt;source vessel's own context&lt;/strong&gt; — &lt;code&gt;vessels.&amp;lt;mmsi&amp;gt;.notifications.mob&lt;/code&gt; for MOB, &lt;code&gt;notifications.distress&lt;/code&gt; for SART/EPIRB. Jump to the convention.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our stack has two plugins that hear other people's emergencies. &lt;a href="https://github.com/sailingnaturali/signalk-dsc" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-dsc&lt;/code&gt;&lt;/a&gt; receives VHF DSC calls — a MAYDAY on channel 70 arrives as &lt;code&gt;$--DSC&lt;/code&gt; sentences or PGN 129808. &lt;a href="https://github.com/sailingnaturali/signalk-ais-distress" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ais-distress&lt;/code&gt;&lt;/a&gt; alerts on AIS survival beacons — SART, MOB, and EPIRB devices, recognizable by their MMSI prefixes (970 / 972 / 974). Both are built on a shared library, &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-distress-core&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Both plugins originally did the obvious thing: raise a notification under your own vessel.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# signalk-dsc ≤ 0.6.x — a received MAYDAY, raised on self
vessels.self.notifications.dsc.distress                state: emergency

# signalk-ais-distress ≤ 0.2.x — a heard MOB beacon, raised on self
vessels.self.notifications.ais.distress.mob            state: emergency
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That fires the alarm chain, which is the point. But it puts &lt;em&gt;someone else's emergency&lt;/em&gt; in the same subtree as &lt;em&gt;your own vessel's&lt;/em&gt; alarm state, and that conflation bites.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: &lt;code&gt;notifications.*&lt;/code&gt; on self means "something is wrong with MY boat"
&lt;/h2&gt;

&lt;p&gt;In the SignalK data model, notifications are per-context. &lt;code&gt;vessels.self.notifications.mob&lt;/code&gt; means &lt;strong&gt;your&lt;/strong&gt; crew is in the water. &lt;code&gt;vessels.self.notifications.grounding&lt;/code&gt; means &lt;strong&gt;you&lt;/strong&gt; are aground. The spec's alarm vocabulary (&lt;code&gt;mob&lt;/code&gt;, &lt;code&gt;fire&lt;/code&gt;, &lt;code&gt;sinking&lt;/code&gt;, &lt;code&gt;flooding&lt;/code&gt;, &lt;code&gt;collision&lt;/code&gt;, &lt;code&gt;grounding&lt;/code&gt;, &lt;code&gt;listing&lt;/code&gt;, &lt;code&gt;adrift&lt;/code&gt;, &lt;code&gt;piracy&lt;/code&gt;, &lt;code&gt;abandon&lt;/code&gt;) describes the vessel the notification hangs off.&lt;/p&gt;

&lt;p&gt;Everything downstream is built on that reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Annunciators and dashboards&lt;/strong&gt; render &lt;code&gt;vessels.self.notifications.*&lt;/code&gt; as own-vessel health. With the old paths, a boat sinking three miles away shows up looking like &lt;em&gt;your&lt;/em&gt; hull alarm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Muting and acknowledgment&lt;/strong&gt; are own-vessel operations. "Silence my alarms" should not be able to swallow a stranger's MAYDAY — and conversely, a received alert you've assessed shouldn't keep tripping logic that watches self state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automations&lt;/strong&gt; keying on self notifications (shutdowns, all-hands triggers) must be able to tell "we are in distress" from "we heard distress."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;notifications.dsc.distress&lt;/code&gt; under self says the first thing when it means the second. This came up in discussion with the SignalK community, and talking it through made the distinction crisp: a distress heard from another MMSI is &lt;em&gt;data about that vessel&lt;/em&gt;, received by you — not your state.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we considered
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Leave it on self under &lt;code&gt;notifications.dsc.*&lt;/code&gt; (status quo).&lt;/strong&gt; Fires the alarm chain, but conflates own-vessel and other-vessel state — the whole problem above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write it &lt;em&gt;only&lt;/em&gt; into the source vessel's context.&lt;/strong&gt; The spec-pure answer: a notification under &lt;code&gt;vessels.&amp;lt;mmsi&amp;gt;&lt;/code&gt; is that vessel's alarm. But nothing on your boat subscribes to arbitrary other-vessel notification paths, so your own annunciator never fires — a MAYDAY you heard and silently filed is worse than the conflation. And it doesn't even cover every case: a SART or personal EPIRB isn't necessarily a vessel at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invent device-type leaves in the target context&lt;/strong&gt; (&lt;code&gt;notifications.sart&lt;/code&gt;, &lt;code&gt;notifications.epirb&lt;/code&gt;). Not spec natures, and a beacon with a 972 MMSI may be a crew-worn personal device — a device-type leaf misdescribes what's happening (a &lt;em&gt;person in the water&lt;/em&gt;, a &lt;em&gt;station in distress&lt;/em&gt;), and consumers would have to learn our invented vocabulary.&lt;/p&gt;

&lt;p&gt;None of the single-write options work, which is the actual finding: &lt;strong&gt;actuation and state are different consumers, and they want the record in different places.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The convention: two writes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Write 1 — self, under &lt;code&gt;notifications.received.*&lt;/code&gt;.&lt;/strong&gt; This is the actuation layer: the path your own alarm chain, voice pipeline, and annunciators subscribe to. The &lt;code&gt;received&lt;/code&gt; segment says exactly what it is — received &lt;em&gt;about another vessel&lt;/em&gt;. Each call raises its &lt;em&gt;own&lt;/em&gt; per-call path — the leaf is &lt;code&gt;&amp;lt;transport&amp;gt;-&amp;lt;id&amp;gt;&lt;/code&gt;, so two concurrent MAYDAYs never stomp one key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# signalk-dsc 0.9.0 — one alarm per call, at its own path
vessels.self.notifications.received.distress.dsc-&amp;lt;id&amp;gt;     state: emergency
vessels.self.notifications.received.urgency.dsc-&amp;lt;id&amp;gt;      state: alarm
vessels.self.notifications.received.safety.dsc-&amp;lt;id&amp;gt;       state: warn

# signalk-ais-distress 0.5.0 — SART, MOB, and EPIRB all raise under distress
vessels.self.notifications.received.distress.ais-&amp;lt;id&amp;gt;     state: emergency
vessels.self.notifications.received.&amp;lt;category&amp;gt;.ais-&amp;lt;id&amp;gt;   # AIS Msg 14 relays
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;&amp;lt;id&amp;gt;&lt;/code&gt; is &lt;code&gt;&amp;lt;receivedAt&amp;gt;-&amp;lt;mmsi&amp;gt;&lt;/code&gt; with the ISO timestamp's &lt;code&gt;.&lt;/code&gt; and &lt;code&gt;:&lt;/code&gt; stripped, so the whole id stays a single path segment instead of splitting into extra ones.&lt;/p&gt;

&lt;p&gt;Clearing now has &lt;strong&gt;two levels&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-call ack&lt;/strong&gt; — a PUT to the exact per-call path you see (&lt;code&gt;notifications.received.distress.ais-&amp;lt;id&amp;gt;&lt;/code&gt;) clears &lt;em&gt;that one call&lt;/em&gt; and stamps the store so a restart re-raise skips it. This is SignalK's standard notification-ack: you acknowledge the alarm in front of you, and only it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk clear-by-category&lt;/strong&gt; — a PUT to a fixed control path drops &lt;em&gt;every&lt;/em&gt; live call of that category at once:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# control paths — PUT clears all live calls of that type (transport-first)
vessels.self.notifications.received.dsc.&amp;lt;category&amp;gt;            # dsc.distress / .urgency / .safety
vessels.self.notifications.received.ais.distress.&amp;lt;beacon&amp;gt;     # ais.distress.sart / .mob / .epirb
vessels.self.notifications.received.ais.broadcast.&amp;lt;category&amp;gt;  # AIS Msg 14 relays
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the segment order flips: a &lt;em&gt;raised&lt;/em&gt; alarm is category-first (&lt;code&gt;received.distress.dsc-&amp;lt;id&amp;gt;&lt;/code&gt;); a &lt;em&gt;control&lt;/em&gt; path is transport-first (&lt;code&gt;received.dsc.distress&lt;/code&gt;). Either way you're managing your &lt;em&gt;response&lt;/em&gt; to someone else's emergency, not your own vessel's condition.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write 2 — the source's own context.&lt;/strong&gt; This is the interoperable state record: the distress hung on the vessel it's actually about, exactly as if that vessel had raised it — which, over the radio, it did. From &lt;code&gt;signalk-ais-distress&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="c1"&gt;// Mirror signalk-dsc: besides the self alarm, surface the distress in the&lt;/span&gt;
&lt;span class="c1"&gt;// *source vessel's* context, so a consumer reading that AIS target sees its&lt;/span&gt;
&lt;span class="c1"&gt;// emergency — as if the vessel raised it. The self notifications.received.*&lt;/span&gt;
&lt;span class="c1"&gt;// alarm is the actuation layer our own annunciator subscribes to; this is&lt;/span&gt;
&lt;span class="c1"&gt;// the interoperable state record.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;notifyTarget&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="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;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mmsi&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leaf&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;deviceBeacon&lt;/span&gt; &lt;span class="o"&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;distress&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&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;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;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sound&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="nx"&gt;event&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="k"&gt;if &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;receivedAt&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;timestamp&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="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;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`vessels.urn:mrn:imo:mmsi:&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;mmsi&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="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="s2"&gt;`notifications.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;leaf&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;value&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// Also feed the flat legacy self-key so existing MOB subscribers (e.g.&lt;/span&gt;
  &lt;span class="c1"&gt;// meshtastic waypoint minting) keep firing until they migrate to the&lt;/span&gt;
  &lt;span class="c1"&gt;// received.* / per-vessel scheme (SK spec thread 2026-07-15). Default&lt;/span&gt;
  &lt;span class="c1"&gt;// context is self — this is the one deliberate own-vessel double-write.&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;leaf&lt;/span&gt; &lt;span class="o"&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notifications.mob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="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;So a heard MOB beacon (fake MMSI for illustration) produces three writes — the per-call self alarm, the source-context state record, and the legacy self-key shim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vessels.self.notifications.received.distress.ais-&amp;lt;id&amp;gt;     ← your alarm fires
vessels.urn:mrn:imo:mmsi:972999999.notifications.mob      ← the beacon's own state
vessels.self.notifications.mob                            ← legacy self-key (back-compat)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why those leaves
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MOB → &lt;code&gt;notifications.mob&lt;/code&gt;.&lt;/strong&gt; It's a real spec nature, and it's what existing consumers key on — &lt;a href="https://github.com/meri-imperiumi/signalk-meshtastic" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-meshtastic&lt;/code&gt;&lt;/a&gt;-style bridges watch other-vessel &lt;code&gt;notifications.mob&lt;/code&gt; to mint a MOB waypoint on the mesh. Using the spec leaf means those consumers work with no knowledge of our plugin.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SART/EPIRB → &lt;code&gt;notifications.distress&lt;/code&gt;.&lt;/strong&gt; These beacons carry no nature-of-distress, and the source may be a personal device rather than a vessel — so neither a nature leaf nor a device-type leaf is honest. &lt;code&gt;distress&lt;/code&gt; states the one thing actually known: this station is in distress.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DSC → &lt;code&gt;notifications.&amp;lt;nature&amp;gt;&lt;/code&gt; under the caller.&lt;/strong&gt; A DSC distress alert &lt;em&gt;does&lt;/em&gt; carry a nature-of-distress code, and the ITU table maps almost one-to-one onto the spec's alarm vocabulary — &lt;code&gt;sinking&lt;/code&gt;, &lt;code&gt;fire&lt;/code&gt;, &lt;code&gt;flooding&lt;/code&gt;, &lt;code&gt;grounding&lt;/code&gt;, &lt;code&gt;mob&lt;/code&gt;, and so on. So &lt;code&gt;signalk-dsc&lt;/code&gt; writes the caller-context record at the real nature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;vessels.self.notifications.received.distress.dsc-&amp;lt;id&amp;gt;      ← your alarm fires
sar.urn:mrn:imo:mmsi:366999999.notifications.sinking       ← the caller's own state
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the context prefix: a DSC &lt;strong&gt;distress&lt;/strong&gt; caller is emitted under the Search-and-Rescue context (&lt;code&gt;sar.&lt;/code&gt; instead of &lt;code&gt;vessels.&lt;/code&gt;), which chartplotters like Freeboard-SK render as a distress/SaR target rather than an ordinary AIS contact. Urgency and safety callers stay under &lt;code&gt;vessels.&lt;/code&gt; like any other contact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more beats from the same release train
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Received alerts survive a restart.&lt;/strong&gt; SignalK notifications are in-memory — a server bounce mid-incident would silently drop an active MAYDAY. Both plugins re-raise every still-fresh, un-acked call on startup, each at its own per-call path (details in the &lt;a href="https://dev.to%20post_url%202026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808%20"&gt;original signalk-dsc post&lt;/a&gt;); a per-call ack stamps the store so the re-raise skips one you've already handled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Distress &lt;em&gt;relays&lt;/em&gt; get attributed to the casualty, not the relaying station.&lt;/strong&gt; A coast station retransmitting a distress alert puts the &lt;em&gt;casualty's&lt;/em&gt; MMSI, nature, and position in different sentence fields than a first-hand alert — the stock parser read the relay's fields as if they were the casualty's, pinning the emergency on the coast station. Fixed upstream in &lt;a href="https://github.com/SignalK/nmea0183-signalk/pull/344" rel="noopener noreferrer"&gt;SignalK/nmea0183-signalk#344&lt;/a&gt;.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Heard&lt;/th&gt;
&lt;th&gt;Self (actuation, one per call)&lt;/th&gt;
&lt;th&gt;Source's own context (state)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DSC distress (Ch 70 / PGN 129808)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notifications.received.distress.dsc-&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sar.&amp;lt;mmsi&amp;gt;&lt;/code&gt; → &lt;code&gt;notifications.&amp;lt;nature&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DSC urgency / safety&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notifications.received.&amp;lt;category&amp;gt;.dsc-&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vessels.&amp;lt;mmsi&amp;gt;&lt;/code&gt; → position only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AIS SART / EPIRB (970 / 974)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notifications.received.distress.ais-&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vessels.&amp;lt;mmsi&amp;gt;&lt;/code&gt; → &lt;code&gt;notifications.distress&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AIS MOB (972)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notifications.received.distress.ais-&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vessels.&amp;lt;mmsi&amp;gt;&lt;/code&gt; → &lt;code&gt;notifications.mob&lt;/code&gt; (+ legacy &lt;code&gt;notifications.mob&lt;/code&gt; on self)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AIS Msg 14 broadcast&lt;/td&gt;
&lt;td&gt;&lt;code&gt;notifications.received.&amp;lt;category&amp;gt;.ais-&amp;lt;id&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "Self" column is the &lt;em&gt;raised&lt;/em&gt; per-call path; a PUT there acks that one call. The matching &lt;strong&gt;bulk clear-by-category control paths&lt;/strong&gt; are transport-first and category-tailed — &lt;code&gt;notifications.received.dsc.&amp;lt;category&amp;gt;&lt;/code&gt;, &lt;code&gt;notifications.received.ais.distress.&amp;lt;beacon&amp;gt;&lt;/code&gt;, &lt;code&gt;notifications.received.ais.broadcast.&amp;lt;category&amp;gt;&lt;/code&gt; — and a PUT to one drops every live call of that type.&lt;/p&gt;

&lt;p&gt;One rule underneath all of it: &lt;strong&gt;a notification's context names whose emergency it is.&lt;/strong&gt; Self gets the &lt;code&gt;received.*&lt;/code&gt; alarm because &lt;em&gt;you&lt;/em&gt; need to act; the source context gets the spec-native record because &lt;em&gt;they&lt;/em&gt; are the ones in distress. The one deliberate exception is MOB: alongside the source-context &lt;code&gt;notifications.mob&lt;/code&gt;, both plugins &lt;em&gt;also&lt;/em&gt; write a flat &lt;code&gt;notifications.mob&lt;/code&gt; on &lt;strong&gt;self&lt;/strong&gt; — a back-compat shim so existing MOB subscribers (meshtastic waypoint minting) keep firing until they migrate to the &lt;code&gt;received.*&lt;/code&gt; / per-vessel scheme. It bends the rule on purpose, and it's the one place a received alarm still lands on a bare self path.&lt;/p&gt;

&lt;p&gt;This is one piece of an all-electric charter-catamaran ops stack — when the radio hears someone in trouble, the data model should say so without claiming the trouble is yours. Code: &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;a href="https://github.com/sailingnaturali/signalk-ais-distress" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-ais-distress&lt;/code&gt;&lt;/a&gt;, shared &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;&lt;code&gt;signalk-distress-core&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: &lt;a href="https://dev.to%20post_url%202026-06-11-signalk-dsc-distress-call-logging-nmea0183-dse-pgn-129808%20"&gt;Logging VHF DSC distress calls in SignalK (PGN 129808)&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>signalk</category>
      <category>marine</category>
      <category>dsc</category>
      <category>ais</category>
    </item>
    <item>
      <title>When AI agents write the ship's log, record who wrote what</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Fri, 24 Jul 2026 17:22:29 +0000</pubDate>
      <link>https://dev.to/clarkbw--/when-ai-agents-write-the-ships-log-record-who-wrote-what-1eg8</link>
      <guid>https://dev.to/clarkbw--/when-ai-agents-write-the-ships-log-record-who-wrote-what-1eg8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — When humans, vessel automation, and an AI agent all write the&lt;br&gt;
same logbook, you need per-entry provenance. We added an optional&lt;br&gt;
&lt;code&gt;origin: manual | auto | agent&lt;/code&gt; field upstream in&lt;br&gt;
&lt;a href="https://github.com/meri-imperiumi/signalk-logbook" rel="noopener noreferrer"&gt;signalk-logbook&lt;/a&gt;&lt;br&gt;
(&lt;a href="https://github.com/meri-imperiumi/signalk-logbook/pull/88" rel="noopener noreferrer"&gt;PR #88&lt;/a&gt;,&lt;br&gt;
released in 0.11.0), made consumers derive it from author conventions for&lt;br&gt;
legacy entries, and put the EU's recommended AI label on agent-written&lt;br&gt;
lines. Jump to the design.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Our ship's log has three kinds of writers. A human types (or dictates) a&lt;br&gt;
line. The logbook plugin itself writes unattended entries — hourly underway&lt;br&gt;
logs, notification state changes, crew changes. And our voice agent writes&lt;br&gt;
entries through the REST API: "log this moment", drill summaries, distress&lt;br&gt;
receptions. All three land in the same per-day YAML files.&lt;/p&gt;

&lt;p&gt;The question this post answers: &lt;strong&gt;six months from now, reading a day back,&lt;br&gt;
how do you know which lines a human actually wrote?&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Problem
&lt;/h2&gt;

&lt;p&gt;Here's a day file from our archive before this work. Three entries, three&lt;br&gt;
different writers — and the only provenance is accidental:&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2026-06-11T08:01:00.000Z'&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Motoring&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;at&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6.1kt'&lt;/span&gt;
  &lt;span class="na"&gt;author&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="c1"&gt;# plugin's own hourly writer&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2026-06-11T09:14:00.000Z'&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MOB&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;drill&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;completed,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;4&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;min&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;recovery&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;[drill:mob]'&lt;/span&gt;
  &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;hermes'&lt;/span&gt;              &lt;span class="c1"&gt;# the agent's API token principal&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;2026-06-11T10:02:00.000Z'&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Sail&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;change,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;reefed&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;main'&lt;/span&gt;
  &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;bryan'&lt;/span&gt;               &lt;span class="c1"&gt;# a person, via the web UI&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The empty author means "the plugin wrote it" only because the plugin's&lt;br&gt;
unattended writers happen to call &lt;code&gt;stateToEntry(state, text)&lt;/code&gt; without an&lt;br&gt;
author. The agent entries say &lt;code&gt;hermes&lt;/code&gt; only because that's the name on the&lt;br&gt;
authentication token the MCP server holds. Nothing in the data model says&lt;br&gt;
&lt;em&gt;this line was composed by an AI&lt;/em&gt;. Readers — agents summarizing a passage,&lt;br&gt;
a morning briefing, a human reviewing the log — cannot reliably tell a&lt;br&gt;
human-authored line from machine output.&lt;/p&gt;

&lt;p&gt;That used to be a nice-to-have. It increasingly isn't: agent-written records&lt;br&gt;
need labeling. The EU has published&lt;br&gt;
&lt;a href="https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content" rel="noopener noreferrer"&gt;recommended icons for labelling AI-generated content&lt;/a&gt;,&lt;br&gt;
and a logbook is exactly the kind of record where "who wrote this" matters&lt;br&gt;
for trust and for corrections.&lt;/p&gt;
&lt;h2&gt;
  
  
  Designs we rejected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Overloading &lt;code&gt;author&lt;/code&gt;.&lt;/strong&gt; Stuffing provenance into the author string&lt;br&gt;
(&lt;code&gt;author: "bryan (via agent)"&lt;/code&gt;) destroys the one clean signal the archive&lt;br&gt;
already has, and every consumer grows a parser.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A structured &lt;code&gt;source&lt;/code&gt; object.&lt;/strong&gt; &lt;code&gt;source: {kind: agent, model: …,&lt;br&gt;
version: …}&lt;/code&gt; is the flexible version nobody asked for. One enum answers the&lt;br&gt;
actual question; a nested object is schema surface you maintain forever.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Asking the speaker to identify themselves.&lt;/strong&gt; For voice entries, "who is&lt;br&gt;
this?" before every log line is terrible ergonomics, and our STT stack&lt;br&gt;
(Whisper) has no speaker recognition anyway. Assume-and-confirm beats&lt;br&gt;
interrogate (more below).&lt;/p&gt;
&lt;h2&gt;
  
  
  The design
&lt;/h2&gt;

&lt;p&gt;One optional enum, and a sharp rule for what it means:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;author&lt;/code&gt; = who is responsible. &lt;code&gt;origin&lt;/code&gt; = who composed the words.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the watchkeeper model from paper logbooks: the officer on watch&lt;br&gt;
signs the page even when the instruments took the reading. Responsibility&lt;br&gt;
and authorship are different columns.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;manual&lt;/code&gt; — a person composed the text. Typed &lt;em&gt;or dictated&lt;/em&gt;: a
voice-dictated line is &lt;code&gt;manual&lt;/code&gt;, because the voice pipeline is just the
pen.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;agent&lt;/code&gt; — an AI composed the text (drill summaries, &lt;code&gt;mark_moment&lt;/code&gt;
phrasings).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;auto&lt;/code&gt; — unattended machinery (hourly logs, notification triggers).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dictation point is the subtle one. If you classify by &lt;em&gt;transport&lt;/em&gt;&lt;br&gt;
(API-written = machine), every voice entry becomes machine output and the&lt;br&gt;
label loses its meaning. Classify by &lt;em&gt;who chose the words&lt;/em&gt;.&lt;/p&gt;
&lt;h3&gt;
  
  
  Forward-compatible rollout
&lt;/h3&gt;

&lt;p&gt;The archive had 72 entries and zero explicit provenance. We refused to&lt;br&gt;
migrate files, and we didn't want to block on the upstream release either.&lt;br&gt;
So the rollout is two-sided:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Writers stamp &lt;code&gt;origin&lt;/code&gt; now.&lt;/strong&gt; Our MCP server started sending
&lt;code&gt;origin: "agent"&lt;/code&gt; in POST bodies before upstream knew the field existed —
the server's field whitelist silently dropped it, a harmless no-op. The
day the upstream release landed on the boat server, the stamps became
effective. No coordination needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumers derive &lt;code&gt;origin&lt;/code&gt; for legacy entries&lt;/strong&gt; from the author
conventions the archive already encodes. Explicit field wins when
present; heuristics fill the past.&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  Upstream: PR #88
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/meri-imperiumi/signalk-logbook/pull/88" rel="noopener noreferrer"&gt;PR #88&lt;/a&gt; (merged,&lt;br&gt;
released in signalk-logbook 0.11.0) adds the field end to end. The core is&lt;br&gt;
one parameter:&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;// plugin/format.js&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;stateToEntry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;navigation.datetime&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="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="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="c1"&gt;// …&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The unattended writers — notification entries, trigger entries (autopilot,&lt;br&gt;
crew, sail changes), the hourly underway log — pass &lt;code&gt;origin: 'auto'&lt;/code&gt;.&lt;br&gt;
&lt;code&gt;POST /logs&lt;/code&gt; accepts both &lt;code&gt;author&lt;/code&gt; and &lt;code&gt;origin&lt;/code&gt; in the body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// plugin/entryFields.js&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&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;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Delegation: an authenticated client (voice assistant, crew app) writes&lt;/span&gt;
  &lt;span class="c1"&gt;// the line on behalf of the person who authored it.&lt;/span&gt;
  &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&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;auto&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;agent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;author&lt;/code&gt; half matters as much as the &lt;code&gt;origin&lt;/code&gt; half: it lets an&lt;br&gt;
authenticated client write a line &lt;em&gt;on someone's behalf&lt;/em&gt; and sign it for&lt;br&gt;
them. &lt;code&gt;PUT&lt;/code&gt; already honored a body-supplied author; this made &lt;code&gt;POST&lt;/code&gt;&lt;br&gt;
consistent. Invalid origins are ignored rather than erroring — an optional&lt;br&gt;
provenance field should never bounce a log entry.&lt;/p&gt;

&lt;p&gt;Two improvements came out of review with the maintainer:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy entries get a definite origin at read time.&lt;/strong&gt; Rather than making&lt;br&gt;
every consumer handle "field absent", the plugin defaults it when reading —&lt;br&gt;
same pattern as its read-time &lt;code&gt;category&lt;/code&gt; default:&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;// plugin/Log.js — reading a stored day file&lt;/span&gt;
&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;author&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;manual&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;auto&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;Stored origins are preserved; authorless legacy entries read as &lt;code&gt;auto&lt;/code&gt;&lt;br&gt;
(they were the plugin's own), authored ones as &lt;code&gt;manual&lt;/code&gt;. Consumers always&lt;br&gt;
see a definite value.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent entries get the EU AI label in the UI.&lt;/strong&gt; The maintainer pointed at&lt;br&gt;
the &lt;a href="https://digital-strategy.ec.europa.eu/en/policies/eu-icons-labelling-ai-generated-content" rel="noopener noreferrer"&gt;EU's AI-content labelling icons&lt;/a&gt;;&lt;br&gt;
the badge renders next to the author on &lt;code&gt;agent&lt;/code&gt; entries in the logbook&lt;br&gt;
table, timeline, and entry viewer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/components/OriginBadge.jsx&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;OriginBadge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&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;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;agent&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"img"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"AI-generated entry"&lt;/span&gt; &lt;span class="cm"&gt;/* inlined EU "AI" icon */&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;title&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;AI-generated entry&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;title&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt;&lt;span class="p"&gt;&amp;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 tooltip text is deliberate — the EU's own testing found icon + text&lt;br&gt;
label beats the icon alone. &lt;code&gt;manual&lt;/code&gt; and &lt;code&gt;auto&lt;/code&gt; entries render untouched:&lt;br&gt;
the label marks AI output, it doesn't decorate everything.&lt;/p&gt;

&lt;p&gt;A companion PR,&lt;br&gt;
&lt;a href="https://github.com/meri-imperiumi/signalk-logbook/pull/92" rel="noopener noreferrer"&gt;#92&lt;/a&gt;, rounds out&lt;br&gt;
the responsibility side: it snapshots &lt;code&gt;communication.skipperName&lt;/code&gt; into every&lt;br&gt;
entry next to the existing &lt;code&gt;crewNames&lt;/code&gt;, and logs skipper handoffs&lt;br&gt;
automatically. With authorship (&lt;code&gt;origin&lt;/code&gt;), responsibility (&lt;code&gt;author&lt;/code&gt;), and&lt;br&gt;
command (&lt;code&gt;skipperName&lt;/code&gt;) each in their own field, you can derive things like&lt;br&gt;
skipper-vs-crew days for sea-service records straight from the log.&lt;/p&gt;
&lt;h2&gt;
  
  
  Consumer side: deriving origin in the MCP server
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/sailingnaturali/logbook-mcp" rel="noopener noreferrer"&gt;logbook-mcp&lt;/a&gt; is the tool&lt;br&gt;
surface our agents use. Its read path implements the legacy heuristic —&lt;br&gt;
explicit field first, then author conventions:&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;derive_origin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;entry&lt;/span&gt;&lt;span class="p"&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;agent_authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;frozenset&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;=&lt;/span&gt; &lt;span class="n"&gt;DEFAULT_AGENT_AUTHORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# {"hermes", "poseidon"}
&lt;/span&gt;    &lt;span class="n"&gt;auto_authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;frozenset&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;=&lt;/span&gt; &lt;span class="nf"&gt;frozenset&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;origin&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;origin&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ORIGINS&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;origin&lt;/span&gt;            &lt;span class="c1"&gt;# explicit field wins (post-upstream entries)
&lt;/span&gt;    &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;entry&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&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;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;            &lt;span class="c1"&gt;# the plugin's own unattended writers
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;agent_authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;           &lt;span class="c1"&gt;# a known agent token principal
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;auto_authors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;            &lt;span class="c1"&gt;# a known automation token principal
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;manual&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;              &lt;span class="c1"&gt;# any other author is a person
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principal lists are configuration, because they're deployment facts:&lt;br&gt;
&lt;em&gt;your&lt;/em&gt; agent tokens have &lt;em&gt;your&lt;/em&gt; names. Every entry returned by&lt;br&gt;
&lt;code&gt;read_entries&lt;/code&gt; carries a derived &lt;code&gt;origin&lt;/code&gt;, and the tool grew an &lt;code&gt;origin&lt;/code&gt;&lt;br&gt;
filter so "show me only the human entries from Tuesday" is one call.&lt;/p&gt;

&lt;p&gt;The write path stamps by composition, per the manual-vs-agent rule:&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;# mark_moment: default origin="agent" — the agent composed the phrasing.
# A dictated line passes origin="manual" — the human composed it.
&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post_entry&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="n"&gt;category&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;a href="https://github.com/sailingnaturali/signalk-distress-core" rel="noopener noreferrer"&gt;signalk-distress-core&lt;/a&gt;&lt;br&gt;
— the shared library behind our DSC and AIS-distress plugins, which writes&lt;br&gt;
received distress traffic into the logbook — stamps &lt;code&gt;auto&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="c1"&gt;// lib/logbook.js — a DSC reception is a plugin-automatic reaction,&lt;/span&gt;
&lt;span class="c1"&gt;// not agent reasoning. Ignored by signalk-logbook until its&lt;/span&gt;
&lt;span class="c1"&gt;// origin-field PR ships, then explicit.&lt;/span&gt;
&lt;span class="nx"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auto&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;That last classification is worth pausing on. The distress writer runs&lt;br&gt;
inside an "AI stack", but no model composes those lines — a parser does.&lt;br&gt;
&lt;code&gt;origin&lt;/code&gt; describes the composer, not the vibe of the system it lives in.&lt;/p&gt;
&lt;h2&gt;
  
  
  Voice attribution: assume, confirm, correct
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;author&lt;/code&gt; delegation in POST unlocked the piece users actually feel.&lt;br&gt;
When someone dictates a log entry, the stack has no speaker ID — so the&lt;br&gt;
agent &lt;em&gt;assumes&lt;/em&gt; the author (the person on watch; solo default today) and&lt;br&gt;
says the assumption out loud. &lt;code&gt;mark_moment&lt;/code&gt;'s return value is a&lt;br&gt;
ready-to-speak confirmation that ends with the attribution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Logged. Entry 4. 14:32. 48.76°N 123.2°W. Logged as Bryan.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the assumption is wrong, the speaker just says so, and the correction&lt;br&gt;
path is its own tool:&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;amend_entry_author&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;entry_id&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="n"&gt;author&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;Reattribute an existing entry — the voice-correction path.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# fetch the day, find the entry, PUT it back with the corrected author
&lt;/span&gt;    &lt;span class="bp"&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;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;entry_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;author&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confirmation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Corrected. Entry now logged as &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="si"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"No, that was Sarah" → &lt;code&gt;amend_entry_author&lt;/code&gt; → "Corrected. Entry now logged&lt;br&gt;
as Sarah." Assume-and-confirm costs one spoken clause; interrogating the&lt;br&gt;
speaker before every entry would cost the feature.&lt;/p&gt;

&lt;p&gt;One implementation wrinkle: at the time we shipped this, upstream &lt;code&gt;POST&lt;/code&gt;&lt;br&gt;
couldn't set &lt;code&gt;author&lt;/code&gt; yet (only &lt;code&gt;PUT&lt;/code&gt; honored it), so &lt;code&gt;mark_moment&lt;/code&gt; POSTs&lt;br&gt;
the entry and then PUTs it back with the body-supplied author. That&lt;br&gt;
two-step still works against older servers — same forward-compatibility&lt;br&gt;
posture as the origin stamps.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provenance you don't record at write time is provenance you reconstruct
forever.&lt;/strong&gt; Our 72-entry archive is small enough that author heuristics
recover it. At 10,000 entries with rotated token names, it wouldn't be.
Writers stamp going forward; heuristics are strictly for the past.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Classify by composer, not transport.&lt;/strong&gt; API-written ≠ machine-written
(dictation), and inside-an-AI-stack ≠ AI-composed (the DSC parser). Get
this wrong and the AI label becomes noise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Absent optional fields should default at read time, in one place.&lt;/strong&gt; The
read-time default (&lt;code&gt;entry.origin || (entry.author ? 'manual' : 'auto')&lt;/code&gt;)
means no consumer ever branches on "field missing". Cheap in the plugin,
expensive everywhere else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ship writers before the schema lands.&lt;/strong&gt; A field the server's whitelist
drops is a free forward-compatibility bet: harmless today, effective the
day the upstream release deploys — with no lockstep upgrade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Labeling agent output is becoming table stakes.&lt;/strong&gt; The EU publishes
ready-made icons for exactly this; wiring one into a hobby-scale marine
plugin took ~30 lines. If your agents write records humans later trust,
there's little excuse not to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We run this stack on the boat-agent system behind an all-electric charter&lt;br&gt;
catamaran project — the ship's log is on the ship, and now it tells you who&lt;br&gt;
held the pen. The MCP side lives at&lt;br&gt;
&lt;a href="https://github.com/sailingnaturali/logbook-mcp" rel="noopener noreferrer"&gt;sailingnaturali/logbook-mcp&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related: how the logbook ended up on the SignalK server in the first&lt;br&gt;
place — &lt;a href="https://dev.to%20post_url%202026-06-06-adopt-vs-build-ships-log-signalk-logbook-mcp%20"&gt;Adopt vs build: why we deleted our working logbook for SignalK&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>signalk</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Two false walls in NOAA's currents API: empty constituents and "not available"</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Thu, 23 Jul 2026 17:28:49 +0000</pubDate>
      <link>https://dev.to/clarkbw--/two-false-walls-in-noaas-currents-api-empty-constituents-and-not-available-42ga</link>
      <guid>https://dev.to/clarkbw--/two-false-walls-in-noaas-currents-api-empty-constituents-and-not-available-42ga</guid>
      <description>&lt;p&gt;This one started with a snap judgment, and the snap judgment was wrong in the most&lt;br&gt;
useful way. I was looking at &lt;a href="https://flaterco.com/xtide/" rel="noopener noreferrer"&gt;XTide&lt;/a&gt;, the reference-grade&lt;br&gt;
open-source tide and current predictor, and my first reaction was: &lt;em&gt;this website looks&lt;br&gt;
like it was built in 1998, I wouldn't trust something this poorly put together.&lt;/em&gt; That&lt;br&gt;
reaction is unfair — XTide's data is impeccable, and the crustiness of a maintainer's&lt;br&gt;
homepage tells you nothing about the quality of the harmonic constants underneath it.&lt;br&gt;
But being wrong about XTide's &lt;em&gt;site&lt;/em&gt; pushed me to the right question about XTide's&lt;br&gt;
&lt;em&gt;role&lt;/em&gt;: do I even need to harvest XTide's data, or can I go straight to the source it&lt;br&gt;
ultimately derives from — NOAA? Chasing that question cost me two full days walking into&lt;br&gt;
walls that both looked like the end of the road. Neither was.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — I wanted US tidal-current harmonic constituents for an offline engine.&lt;br&gt;
&lt;strong&gt;Wall 1:&lt;/strong&gt; NOAA's &lt;code&gt;harcon.json&lt;/code&gt; returned an &lt;strong&gt;empty&lt;/strong&gt; constituent list for current&lt;br&gt;
stations, so I concluded NOAA doesn't publish current constituents. Wrong — it's empty&lt;br&gt;
only at &lt;code&gt;bin=0&lt;/code&gt;; query the station's &lt;strong&gt;&lt;code&gt;currbin&lt;/code&gt;&lt;/strong&gt; and you get 26 real constituents&lt;br&gt;
(Deception Pass M2 = &lt;strong&gt;5.418 kn @ 241.2° GMT&lt;/strong&gt;). &lt;strong&gt;Empty ≠ absent.&lt;/strong&gt; &lt;strong&gt;Wall 2:&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;currents_predictions&lt;/code&gt; returned &lt;em&gt;"not available from the requested station"&lt;/em&gt; for every&lt;br&gt;
station — including NOAA's own documented example — so it looked like a dead product.&lt;br&gt;
Wrong again: &lt;strong&gt;browser User-Agent + a real prediction station + its currbin&lt;/strong&gt; and it&lt;br&gt;
returns data. The service was never down; my request was. Outcome: &lt;strong&gt;XTide dropped&lt;br&gt;
entirely&lt;/strong&gt;, 855 harmonic + ~1,700 subordinate stations straight from NOAA, validated to&lt;br&gt;
&lt;strong&gt;9.7 min / 0.055 kn&lt;/strong&gt; against NOAA's own predictions.&lt;br&gt;
Jump to the validation.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  Plan A, and why it wasn't Plan A for long
&lt;/h2&gt;

&lt;p&gt;The engine already predicted tides from NOAA harmonic constants, offline and validated&lt;br&gt;
to a few minutes against CO-OPS. Currents are the &lt;em&gt;same math&lt;/em&gt; — a sum of cosines that&lt;br&gt;
happens to produce signed velocity instead of height — so the only new input was&lt;br&gt;
current constituents. The obvious source was XTide's Harmbase2 (via libtcd): US current&lt;br&gt;
stations, public-domain-derived, exactly the constants I needed.&lt;/p&gt;

&lt;p&gt;But routing through XTide means adopting XTide's snapshot of the constants, its naming,&lt;br&gt;
its extraction toolchain, and its update cadence. If NOAA publishes the same constituents&lt;br&gt;
directly, I'd rather consume them at the source and skip the middleman entirely. So before&lt;br&gt;
committing to Harmbase2, I spent an hour poking at NOAA's metadata API to see whether the&lt;br&gt;
current constituents were there.&lt;/p&gt;

&lt;p&gt;They appeared not to be. That's Wall 1.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wall 1: "NOAA doesn't publish current constituents"
&lt;/h2&gt;

&lt;p&gt;NOAA's CO-OPS metadata API (&lt;code&gt;mdapi&lt;/code&gt;) serves harmonic constituents for a station at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations/&amp;lt;id&amp;gt;/harcon.json?units=english
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a tide station this returns the full constituent set. For a &lt;em&gt;current&lt;/em&gt; station —&lt;br&gt;
Deception Pass, Rosario Strait, the passes I actually cared about — it returned a valid&lt;br&gt;
JSON envelope with an &lt;strong&gt;empty constituent array&lt;/strong&gt;. Not a 404, not an error. Just: here is&lt;br&gt;
your station, and it has zero harmonic constituents.&lt;/p&gt;

&lt;p&gt;The natural read is that current stations simply don't carry published constituents —&lt;br&gt;
that NOAA serves current &lt;em&gt;predictions&lt;/em&gt; as a finished product but keeps the underlying&lt;br&gt;
harmonics private, the way some hydrographic services do. I wrote that down as a finding&lt;br&gt;
and started building the XTide extractor for real.&lt;/p&gt;

&lt;p&gt;That conclusion was wrong, and the tell was sitting in the station metadata the whole&lt;br&gt;
time. Current stations are three-dimensional in a way tide stations aren't: a current is&lt;br&gt;
measured at a &lt;strong&gt;depth bin&lt;/strong&gt;, and each station has a &lt;em&gt;reference&lt;/em&gt; bin — the depth the&lt;br&gt;
published predictions are computed for. That field is &lt;code&gt;currbin&lt;/code&gt;, and it's right there in&lt;br&gt;
the station-list record:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json?type=currentpredictions&amp;amp;units=english
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each record carries &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;lat&lt;/code&gt;/&lt;code&gt;lng&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt; (H = harmonic, S = subordinate,&lt;br&gt;
W = weak/rotary), and crucially &lt;strong&gt;&lt;code&gt;currbin&lt;/code&gt;&lt;/strong&gt;. The &lt;code&gt;harcon.json&lt;/code&gt; endpoint takes a &lt;code&gt;bin&lt;/code&gt;&lt;br&gt;
parameter, and it defaults to &lt;code&gt;bin=0&lt;/code&gt;. Bin 0 is above the reference depth for these&lt;br&gt;
stations — there's no harmonic solution published there, so the array is legitimately&lt;br&gt;
empty. Ask for the &lt;em&gt;right&lt;/em&gt; bin and the data pours out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;harcon.json?units=english&amp;amp;bin=&amp;lt;currbin&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deception Pass Narrows (&lt;code&gt;PUG1701&lt;/code&gt;, &lt;code&gt;currbin=18&lt;/code&gt;) returns &lt;strong&gt;26 constituents&lt;/strong&gt;, and they're&lt;br&gt;
exactly what a current predictor needs — richer than the tide equivalent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constituentName   "M2"
majorAmplitude    5.418      // knots, major-axis semi-amplitude
majorPhaseGMT     241.2      // Greenwich-referenced phase, degrees
majorMeanSpeed   -0.619      // mean flow along the axis (Z0), knots
azi               92.9       // major-axis azimuth, degrees true
minorAmplitude / minorPhase  // the rotary (cross-axis) component
binNbr / binDepth / constNum
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole model: &lt;code&gt;majorAmplitude&lt;/code&gt; and &lt;code&gt;majorPhaseGMT&lt;/code&gt; drive the sum, &lt;code&gt;azi&lt;/code&gt; gives&lt;br&gt;
the flood axis (ebb is &lt;code&gt;azi&lt;/code&gt;+180), and &lt;code&gt;majorMeanSpeed&lt;/code&gt; is the Z₀ offset for a station&lt;br&gt;
whose flow doesn't average to zero. The empty array wasn't NOAA telling me the data&lt;br&gt;
didn't exist. It was NOAA answering a question I hadn't meant to ask — &lt;em&gt;what are the&lt;br&gt;
constituents at the surface?&lt;/em&gt; — and me reading the answer as &lt;em&gt;there are no constituents.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Empty is not absent.&lt;/strong&gt; An empty result to a query with a defaulted parameter is a&lt;br&gt;
statement about your query, not about the data. That cost me the better part of a day and&lt;br&gt;
an entire wrong architecture.&lt;/p&gt;
&lt;h2&gt;
  
  
  Wall 2: "the predictions API is down"
&lt;/h2&gt;

&lt;p&gt;With constituents in hand I needed an oracle — NOAA's &lt;em&gt;own&lt;/em&gt; predicted slack/max events —&lt;br&gt;
to validate the engine against. That lives on the older Data Retrieval API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://api.tidesandcurrents.noaa.gov/api/prod/datagetter
  ?product=currents_predictions&amp;amp;interval=max_slack&amp;amp;bin=&amp;lt;currbin&amp;gt;
  &amp;amp;station=&amp;lt;id&amp;gt;&amp;amp;begin_date=...&amp;amp;end_date=...&amp;amp;units=english&amp;amp;time_zone=gmt&amp;amp;format=json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every station I tried came back with:&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;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Currents predictions are not available from the requested station"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one. Including the station NOAA uses in its &lt;em&gt;own API documentation&lt;/em&gt; as the worked&lt;br&gt;
example. When even the documented example fails, the honest inference is that the product&lt;br&gt;
is down — CO-OPS was mid cloud-migration that week, so a dead endpoint was entirely&lt;br&gt;
plausible. I nearly wrote it off and shipped with structural validation only.&lt;/p&gt;

&lt;p&gt;It wasn't down. There were &lt;strong&gt;three&lt;/strong&gt; separate things wrong, stacked, each one enough on&lt;br&gt;
its own to produce a failure that &lt;em&gt;looked&lt;/em&gt; like an outage:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(a) NOAA 404s the default User-Agent.&lt;/strong&gt; Plain &lt;code&gt;curl&lt;/code&gt; and Node's &lt;code&gt;fetch&lt;/code&gt; send a&lt;br&gt;
default UA (&lt;code&gt;curl/8.x&lt;/code&gt;, &lt;code&gt;node&lt;/code&gt;), and NOAA's edge returns a bare 404 for it. The exact&lt;br&gt;
same request from a browser — same machine, same residential IP — returns data. Send a&lt;br&gt;
browser &lt;code&gt;User-Agent&lt;/code&gt; header and this failure vanishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;(b) Transient rate-limiting that looks like an IP block.&lt;/strong&gt; My probing volume (I was&lt;br&gt;
hammering the endpoint trying to figure out why it failed) tripped NOAA's throttle, which&lt;br&gt;
also returns bare 404s. I spent an embarrassing stretch convinced NOAA was blocking&lt;br&gt;
&lt;em&gt;datacenter IPs&lt;/em&gt; — I'd seen mdapi 404 from cloud egresses before — until the same box that&lt;br&gt;
was 404ing from a script served the request fine &lt;em&gt;in a browser&lt;/em&gt;. That's not an IP block;&lt;br&gt;
an IP block would fail in the browser too. The variable that changed between the two was&lt;br&gt;
the User-Agent (and the request pacing), not the IP. Slow down and send a real UA and the&lt;br&gt;
404s stop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;(c) Wrong stations at the wrong bin.&lt;/strong&gt; Some of the stations I was hammering are&lt;br&gt;
&lt;em&gt;observation / survey&lt;/em&gt; stations, not published-prediction stations — &lt;code&gt;currents_predictions&lt;/code&gt;&lt;br&gt;
genuinely isn't served for those, correctly. My actual target passes (&lt;code&gt;PUG1701&lt;/code&gt; Deception&lt;br&gt;
Pass, &lt;code&gt;PUG1717&lt;/code&gt; Turn Point) are survey stations NOAA doesn't publish predictions for. But&lt;br&gt;
hundreds of &lt;em&gt;other&lt;/em&gt; harmonic stations are served — &lt;code&gt;PUG1741&lt;/code&gt; (Bellingham Channel),&lt;br&gt;
&lt;code&gt;PUG1612&lt;/code&gt; (Clinton Ferry), &lt;code&gt;SFB1222&lt;/code&gt; — and only when queried at their &lt;code&gt;currbin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fix all three — browser UA, polite pacing, a served station at its reference bin — and&lt;br&gt;
&lt;code&gt;currents_predictions&lt;/code&gt; returns clean data. It was never down. Every failure I saw was my&lt;br&gt;
own request.&lt;/p&gt;

&lt;h2&gt;
  
  
  The turn: verify against a known-good client
&lt;/h2&gt;

&lt;p&gt;What actually broke the logjam wasn't a NOAA status page. It was reading someone else's&lt;br&gt;
working code.&lt;/p&gt;

&lt;p&gt;I found the &lt;a href="https://github.com/RyanCardin15/Perigee-Tides" rel="noopener noreferrer"&gt;Perigee-Tides MCP&lt;/a&gt;, a&lt;br&gt;
third-party client that talks to the same CO-OPS endpoints, and diffed its request&lt;br&gt;
construction (&lt;code&gt;src/services/data-api.ts&lt;/code&gt;, &lt;code&gt;metadata-api.ts&lt;/code&gt;) against mine. Byte for byte,&lt;br&gt;
the URL shape, the parameters, the field names — identical. My request &lt;em&gt;format&lt;/em&gt; was&lt;br&gt;
correct. It had been correct the whole time.&lt;/p&gt;

&lt;p&gt;That's the pivot the whole debugging session turned on, and it generalizes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When your request matches a known-good client and it still fails, the variable is the&lt;br&gt;
&lt;strong&gt;target&lt;/strong&gt;, not the syntax. Stop editing the request and start changing what you point&lt;br&gt;
it at — the station, the bin, the headers, the rate.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once the format was ruled out, the remaining differences could only be the station, the&lt;br&gt;
bin, the UA, and the request rate — which is exactly where the three real problems were.&lt;br&gt;
I'd spent hours suspecting my URL. The URL was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Outcome: XTide dropped entirely
&lt;/h2&gt;

&lt;p&gt;Both walls fell, and on the far side of them the original question answered itself: I&lt;br&gt;
don't need XTide at all. Everything comes straight from NOAA CO-OPS, public domain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Constituents&lt;/strong&gt; — &lt;code&gt;harcon.json&lt;/code&gt; at the station's &lt;code&gt;currbin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flood/ebb axis&lt;/strong&gt; — &lt;code&gt;azi&lt;/code&gt; (flood) and &lt;code&gt;azi&lt;/code&gt;+180 (ebb).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mean flow&lt;/strong&gt; — &lt;code&gt;majorMeanSpeed&lt;/code&gt; as the Z₀ offset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subordinate stations&lt;/strong&gt; — &lt;code&gt;currentpredictionoffsets.json&lt;/code&gt;, giving time and speed
offsets against a reference harmonic station.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The build-time extractor sweeps NOAA's station list and pulls each station's harcon (at&lt;br&gt;
its currbin) and offsets, producing a bundled &lt;code&gt;currents.json&lt;/code&gt; computed offline at runtime&lt;br&gt;
— &lt;strong&gt;855 harmonic + ~1,700 subordinate stations&lt;/strong&gt;, all of US waters, including the US Salish&lt;br&gt;
Sea passes I set out for: Deception Pass, Rosario, San Juan Channel, Turn Point. No XTide&lt;br&gt;
data, no &lt;code&gt;tide&lt;/code&gt; binary, no libtcd, at build time or run time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt: vs NOAA's own predictions
&lt;/h2&gt;

&lt;p&gt;Once &lt;code&gt;currents_predictions&lt;/code&gt; was serving, it became the oracle it was always meant to be —&lt;br&gt;
the independent authority check. Feed the engine a station's published constituents,&lt;br&gt;
predict its slack/max events, and compare to NOAA's &lt;em&gt;own&lt;/em&gt; published events for the same&lt;br&gt;
station and days.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harmonic station — &lt;code&gt;PUG1741&lt;/code&gt; (Bellingham Channel), a clean ~2.8 kn reversing station:&lt;/strong&gt;&lt;br&gt;
max flood/ebb events match NOAA to &lt;strong&gt;9.7 min / 0.055 kn&lt;/strong&gt; across 11 events — on par with&lt;br&gt;
the tide engine's Phase-0 (7.9 min / 3.5 cm). The fixture is captured offline so the check&lt;br&gt;
runs in CI without the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subordinate reduction — &lt;code&gt;PCT0236&lt;/code&gt; (reference &lt;code&gt;SFB1201&lt;/code&gt;):&lt;/strong&gt; &lt;strong&gt;6.1 min / 0.05 kn&lt;/strong&gt; over 11&lt;br&gt;
events. Subordinate stations were subtler than tides because NOAA publishes &lt;strong&gt;two&lt;/strong&gt; slack&lt;br&gt;
offsets — slack-before-flood (&lt;code&gt;sbfTimeAdjMin&lt;/code&gt;) and slack-before-ebb (&lt;code&gt;sbeTimeAdjMin&lt;/code&gt;) —&lt;br&gt;
not one. A slack takes the offset for the phase it &lt;em&gt;precedes&lt;/em&gt;. Model it with a single slack&lt;br&gt;
offset and half your slacks land at the wrong minute.&lt;/p&gt;

&lt;p&gt;One labeling insight fell out of matching NOAA at weak stations: &lt;strong&gt;classify max flood vs&lt;br&gt;
max ebb by the sign of the velocity, not by slope high/low.&lt;/strong&gt; At a strong reversing pass,&lt;br&gt;
a velocity peak (slope zero) that's positive is a flood and negative is an ebb, and&lt;br&gt;
high/low slope gives the same answer. But at a weak, non-reversing station a relaxation&lt;br&gt;
peak never crosses zero — a −0.3 kn local maximum during a long ebb is &lt;em&gt;slope-high&lt;/em&gt; but&lt;br&gt;
it's still an ebb. Label it by sign and you match NOAA's &lt;code&gt;max_slack&lt;/code&gt; exactly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bugs only a batch caught
&lt;/h2&gt;

&lt;p&gt;Validating one station passed clean. Then I validated a &lt;em&gt;diverse batch&lt;/em&gt; — different regions,&lt;br&gt;
offset signs, speed ratios, harmonic and subordinate — and it broke in two different ways the&lt;br&gt;
single-station test had walked right past. Both are the same shape: a category I'd treated as&lt;br&gt;
monolithic turned out to have a second variety, and only the second variety triggered the bug.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A reference is (station, bin), not just a station.&lt;/strong&gt; A subordinate references a specific&lt;br&gt;
&lt;strong&gt;bin&lt;/strong&gt; of its reference station (&lt;code&gt;refStationBin&lt;/code&gt;) — because, same lesson as Wall 1, a current&lt;br&gt;
reference is depth-keyed. My extractor stored &lt;em&gt;one&lt;/em&gt; bin's constituents per reference. That's&lt;br&gt;
fine until a reference publishes multiple bins with &lt;em&gt;different&lt;/em&gt; constituents. &lt;code&gt;SFB1201&lt;/code&gt;&lt;br&gt;
publishes bins &lt;code&gt;[26, 20, 10]&lt;/code&gt;, each with its own harmonic set. Storing one silently resolved&lt;br&gt;
half the subordinates against the &lt;em&gt;wrong&lt;/em&gt; constituents — ~50 min and ~0.6 kn off. The&lt;br&gt;
single-station test had happened to hit the bin I'd stored, so it passed and told me nothing.&lt;br&gt;
Fix: resolve each reference at its exact &lt;code&gt;refStationBin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Subordinate" doesn't always mean subordinate.&lt;/strong&gt; Some NOAA &lt;code&gt;type: S&lt;/code&gt; stations carry their&lt;br&gt;
&lt;em&gt;own&lt;/em&gt; harmonic constituents, and NOAA predicts &lt;em&gt;those&lt;/em&gt; harmonically — the offset reduction is&lt;br&gt;
only for stations that have none. &lt;code&gt;PUG1716&lt;/code&gt; (which references San Juan Channel) is type S but&lt;br&gt;
has its own 25-constituent harcon; reducing it from San Juan Channel over-shot it by &lt;strong&gt;89 min&lt;br&gt;
/ 0.7 kn&lt;/strong&gt;. Predicted from its own constituents: &lt;strong&gt;6.8 min / 0.06 kn&lt;/strong&gt;. Fix: for a type-S&lt;br&gt;
station, fetch its own harcon first; fall back to the reduction only when it's empty. With&lt;br&gt;
both fixes, nine genuinely-subordinate stations match NOAA to &lt;strong&gt;0.9–7.7 min&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The lesson keeps recurring in this engine: &lt;strong&gt;n=1 validation hides class bugs.&lt;/strong&gt; One passing&lt;br&gt;
station proves one station. Validate across the &lt;em&gt;diversity&lt;/em&gt; of the data — regions, bins, ratios,&lt;br&gt;
harmonic and subordinate — and the class of bug that only shows up at the second variety finally&lt;br&gt;
has somewhere to show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The passes I set out for turned out served
&lt;/h2&gt;

&lt;p&gt;There's a fitting last twist. This whole project started to get currents for &lt;em&gt;my&lt;/em&gt; home passes —&lt;br&gt;
Deception Pass, Rosario Strait, San Juan Channel, Turn Point. Early on those returned&lt;br&gt;
&lt;code&gt;"not available"&lt;/code&gt;, so I filed them as survey stations NOAA doesn't predict and validated them&lt;br&gt;
only &lt;em&gt;transitively&lt;/em&gt; — engine-correct-on-other-stations plus faithful constituents. That was Wall&lt;br&gt;
2 in miniature, and I'd quietly surrendered to it.&lt;/p&gt;

&lt;p&gt;Re-checked with the right bin and a browser User-Agent: &lt;strong&gt;every one is served.&lt;/strong&gt; They validate&lt;br&gt;
&lt;em&gt;directly&lt;/em&gt; against NOAA's own predictions now — Deception Pass to 14 min, Admiralty Inlet to 3.5,&lt;br&gt;
Race Rocks to 6.0, on the significant currents. (The weak sub-¾-knot relaxation extrema at the&lt;br&gt;
mixed-tide stations disagree by tens of minutes — but those are ill-conditioned in NOAA's&lt;br&gt;
computation too, and a boat doesn't care when a third of a knot peaks.) The premise that my home&lt;br&gt;
water was unreachable was, like every wall in this story, my request being wrong — not the data&lt;br&gt;
being absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five things I'm keeping
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Empty ≠ absent.&lt;/strong&gt; An empty result to a query with a defaulted parameter (&lt;code&gt;bin=0&lt;/code&gt;) is a
fact about your query, not about the data. Check what you defaulted before you conclude
the data doesn't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"The service is down" is usually your request.&lt;/strong&gt; Three stacked causes — User-Agent,
rate, target — each looked like an outage. The endpoint was serving the whole time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An ugly UI is not evidence about data quality.&lt;/strong&gt; XTide's 1998 homepage says nothing
about its constants. The reaction was wrong, but interrogating it led to the better
architecture — go to the source.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify against a known-good client before you touch your request.&lt;/strong&gt; When your request
matches a working client and still fails, the variable is the target, not the syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validate at scale, not n=1.&lt;/strong&gt; One passing station hid &lt;em&gt;two&lt;/em&gt; bugs here, each triggered
only by a second &lt;em&gt;kind&lt;/em&gt; of station. Test across the diversity of the data — and the passes
you gave up on may turn out served all along.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The engine is MIT-licensed and the currents fixtures are reproducible from NOAA's own&lt;br&gt;
predictions: &lt;a href="https://github.com/sailingnaturali/slackwater-engine" rel="noopener noreferrer"&gt;&lt;strong&gt;slackwater-engine&lt;/strong&gt;&lt;/a&gt;.&lt;br&gt;
If it's off at your home pass, the golden generator points at any served NOAA station — run&lt;br&gt;
it and send a number back.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not for navigation.&lt;/strong&gt; Current predictions are astronomical estimates and don't account&lt;br&gt;
for wind, freshet, or local effects. Carry official current tables and charts.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>currents</category>
      <category>noaa</category>
      <category>coops</category>
      <category>harmonicanalysis</category>
    </item>
    <item>
      <title>The marine SignalK + MCP landscape: what exists, what's actually novel</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 21 Jul 2026 16:03:40 +0000</pubDate>
      <link>https://dev.to/clarkbw--/the-marine-signalk-mcp-landscape-what-exists-whats-actually-novel-2h2d</link>
      <guid>https://dev.to/clarkbw--/the-marine-signalk-mcp-landscape-what-exists-whats-actually-novel-2h2d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — We shipped seven marine MCP servers, &lt;em&gt;then&lt;/em&gt; ran the ecosystem&lt;br&gt;
scan we should have run first. The good news: the niche is sparse, and the&lt;br&gt;
capabilities we bet on (NDBC buoy reality-check, tidal currents, COLREGs&lt;br&gt;
rule text, anchorage comfort ranking) turned out to have no prior art&lt;br&gt;
anywhere in the MCP ecosystem. The bad news: a one-evening scan would still&lt;br&gt;
have changed several decisions. Jump to the map.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This stack runs on a prime directive: &lt;em&gt;use or improve existing tools before&lt;br&gt;
building your own&lt;/em&gt;. We applied it per-component — it's why we&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-adopt-vs-build-ships-log-signalk-logbook-mcp%20"&gt;deleted our working logbook for signalk-logbook&lt;/a&gt;&lt;br&gt;
and why the&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-marine-weather-mcp-buoy-ground-truth-ndbc-spec-swell-wind-waves%20"&gt;weather server got audited against three existing weather MCPs&lt;/a&gt;.&lt;br&gt;
What we never did was scan the &lt;em&gt;whole&lt;/em&gt; ecosystem at once: every marine MCP&lt;br&gt;
server, every SignalK AI integration, every plugin adjacent to what we were&lt;br&gt;
building.&lt;/p&gt;

&lt;p&gt;When we finally ran that scan — seven servers deep — the honest question was:&lt;br&gt;
&lt;em&gt;how much did we just rebuild?&lt;/em&gt; This post is the answer, published as the map&lt;br&gt;
we wish we'd found.&lt;/p&gt;
&lt;h2&gt;
  
  
  The map
&lt;/h2&gt;

&lt;p&gt;Everything here is public; links go to the repos. "Novel" means we found no&lt;br&gt;
prior art doing the capability, in any MCP server, as of the scan.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Ours&lt;/th&gt;
&lt;th&gt;Nearest prior art&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/currents-mcp" rel="noopener noreferrer"&gt;currents-mcp&lt;/a&gt; + &lt;a href="https://github.com/sailingnaturali/signalk-currents" rel="noopener noreferrer"&gt;signalk-currents&lt;/a&gt; (tidal currents, gates, slack windows)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/openwatersio/signalk-tides" rel="noopener noreferrer"&gt;openwatersio/signalk-tides&lt;/a&gt; — tide &lt;em&gt;height&lt;/em&gt; only&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Novel.&lt;/strong&gt; Nobody else does tidal &lt;em&gt;currents&lt;/em&gt;. We forked the right tide base and contributed upstream.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/weather-mcp" rel="noopener noreferrer"&gt;weather-mcp&lt;/a&gt; (marine forecast + NDBC buoy reality-check)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/weather-mcp/weather-mcp" rel="noopener noreferrer"&gt;weather-mcp/weather-mcp&lt;/a&gt; (generic, 17 tools); &lt;a href="https://github.com/RyanCardin15/NOAA-TidesAndCurrents-MCP" rel="noopener noreferrer"&gt;NOAA-TidesAndCurrents-MCP&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;NDBC reality-check is novel&lt;/strong&gt; — no MCP anywhere ingests NDBC observations and diffs them against the forecast. The generic ones were worth studying as tool-design baselines, though.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/pilotbook-mcp" rel="noopener noreferrer"&gt;pilotbook-mcp&lt;/a&gt; (anchorage search + comfort ranking)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/itemir/signalk-console" rel="noopener noreferrer"&gt;itemir/signalk-console&lt;/a&gt; (ActiveCaptain POI display)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Novel.&lt;/strong&gt; No searchable pilot-book with conditions-aware comfort ranking exists; the closest thing is a passive POI widget.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/colregs-mcp" rel="noopener noreferrer"&gt;colregs-mcp&lt;/a&gt; (nav-rules reference + compliance)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/VladimirKalachikhin/collision-detector" rel="noopener noreferrer"&gt;VladimirKalachikhin/collision-detector&lt;/a&gt; (geometric CPA, no rule text)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Novel.&lt;/strong&gt; The only other collision-reasoning attempt is pure geometry — nobody else encodes the COLREGs rule text for agents.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/logbook-mcp" rel="noopener noreferrer"&gt;logbook-mcp&lt;/a&gt; (over &lt;a href="https://github.com/meri-imperiumi/signalk-logbook" rel="noopener noreferrer"&gt;signalk-logbook&lt;/a&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;&lt;a href="https://github.com/xbgmsharp/signalk-postgsail" rel="noopener noreferrer"&gt;PostgSail&lt;/a&gt;&lt;/strong&gt; + &lt;a href="https://github.com/xbgmsharp/postgsail-mcp-server" rel="noopener noreferrer"&gt;postgsail-mcp-server&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Should have found PostgSail pre-build.&lt;/strong&gt; Complementary in the end — it's historical-DB analytics, ours is live capture — but this is the one a scan would have flagged first.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/vessel-knowledge-mcp" rel="noopener noreferrer"&gt;vessel-knowledge-mcp&lt;/a&gt; (equipment spec cards, alarm explain)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/joelkoz/signalk-hour-meter" rel="noopener noreferrer"&gt;joelkoz/signalk-hour-meter&lt;/a&gt; (engine hours off SignalK paths)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Knowledge-cards-as-MCP is novel&lt;/strong&gt;; hour-meter is direct prior art for one planned sub-feature (engine-hours) — see below for why it's reference-only.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/signalk-ntfy-relay" rel="noopener noreferrer"&gt;signalk-ntfy-relay&lt;/a&gt; (push notifications)&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/itemir/signalk-notifications" rel="noopener noreferrer"&gt;itemir/signalk-notifications&lt;/a&gt; (Pushover); &lt;a href="https://github.com/sbender9/signalk-push-notifications" rel="noopener noreferrer"&gt;sbender9/signalk-push-notifications&lt;/a&gt; (SNS)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;ntfy backend is novel&lt;/strong&gt; — everyone else routes through Pushover or SNS. Same job, different (self-hostable, zero-account) delivery path.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;a href="https://github.com/sailingnaturali/signalk-mcp" rel="noopener noreferrer"&gt;signalk-mcp&lt;/a&gt; (live vessel data + alarms for agents)&lt;/td&gt;
&lt;td&gt;VesselSense (the one serious SignalK-MCP peer, code-execution architecture); &lt;a href="https://github.com/SignalK/signalk-server/issues/1867" rel="noopener noreferrer"&gt;SignalK/signalk-server#1867&lt;/a&gt; (native MCP endpoint — open, unassigned)&lt;/td&gt;
&lt;td&gt;Peer exists with an opposite tool philosophy — we compared &lt;a href="https://dev.to%20post_url%202026-06-06-signalk-mcp-named-tools-vs-execute-code-token-efficiency-voice-agent%20"&gt;discrete named tools vs execute-code&lt;/a&gt; at length. Upstream native MCP is still an open issue, so the space is genuinely unclaimed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Also on the map, outside the fleet's footprint:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/SkipperDon/d3kOS" rel="noopener noreferrer"&gt;d3kOS&lt;/a&gt;&lt;/strong&gt; — a Raspberry Pi AI helm
computer: offline voice, engine-diagnostics RAG over PDF manuals, bow-camera
object detection. The manual-RAG and bow-cam pieces are ahead of anything we
run; worth watching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Windward MAI Expert&lt;/strong&gt; — commercial, fleet-scale "alarm → agentic
enrichment → narrative brief." Not something a cruiser can run, but it
independently validates the alarm-triage agent pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saillogger's AIS MCP&lt;/strong&gt; — a narrow stub today, from the same author as
signalk-console.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;
  
  
  What the scan actually found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. We didn't wholesale rebuild — the niche really is sparse.&lt;/strong&gt; Four of&lt;br&gt;
seven servers had &lt;em&gt;no&lt;/em&gt; prior art. That surprised us: MCP servers exist for&lt;br&gt;
seemingly everything, but "marine + operational + agent-facing" is nearly&lt;br&gt;
empty. If you're building here, the ecosystem-scan cost is one evening and&lt;br&gt;
the field is still mostly unclaimed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The one real miss was PostgSail.&lt;/strong&gt; An Apache-2.0 voyage database with&lt;br&gt;
its own actively maintained MCP server — voyage history, trip summaries,&lt;br&gt;
monitoring, maintenance notes — and we found it &lt;em&gt;after&lt;/em&gt; shipping our logbook&lt;br&gt;
integration. The post-hoc assessment landed on "don't adopt the stack, borrow&lt;br&gt;
the tool design" (it's a whole Postgres/Timescale/Grafana tier, aimed at&lt;br&gt;
dashboards; our log lives on the vessel as YAML). But that's a verdict we got&lt;br&gt;
lucky on. A pre-build scan makes it a decision instead of a coin flip.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Licensing is part of prior art.&lt;/strong&gt; &lt;code&gt;signalk-hour-meter&lt;/code&gt; looked like an&lt;br&gt;
adopt candidate for engine-hours — until the scan showed it dormant for&lt;br&gt;
years and, critically, &lt;strong&gt;unlicensed&lt;/strong&gt;. No license means all-rights-reserved:&lt;br&gt;
you can read the pattern, you cannot ship the code. That single check&lt;br&gt;
converted "adopt" into "pattern reference only" in one minute. Check the&lt;br&gt;
license &lt;em&gt;before&lt;/em&gt; you get attached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A name collision is not a collaboration target.&lt;/strong&gt; There's an active,&lt;br&gt;
polished, generic &lt;code&gt;weather-mcp&lt;/code&gt; org whose README states its marine data is&lt;br&gt;
"not suitable for navigation" — the exact opposite of an on-boat operational&lt;br&gt;
tool, and no buoy observations at all. Same name, disjoint philosophy. The&lt;br&gt;
practical lesson: when your repo name is saturated (8+ unrelated&lt;br&gt;
&lt;code&gt;weather-mcp&lt;/code&gt;s exist), always qualify yours by org in docs and cross-refs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The people map is half the value.&lt;/strong&gt; The scan's most useful output&lt;br&gt;
wasn't code at all — it was discovering we were &lt;em&gt;already running&lt;/em&gt; several&lt;br&gt;
ecosystem authors' work without having realized it: the logbook plugin we&lt;br&gt;
adopted, the Victron plugin on our eval list, the radar server we assessed.&lt;br&gt;
A prior-art scan doubles as a map of whose work you depend on, which is&lt;br&gt;
exactly who you should be talking to, contributing back to, and crediting.&lt;/p&gt;
&lt;h2&gt;
  
  
  The lesson, operationalized
&lt;/h2&gt;

&lt;p&gt;The scan cost one evening of agent-assisted searching across GitHub topics,&lt;br&gt;
the SignalK plugin registry, and the MCP directories. In a &lt;em&gt;sparse&lt;/em&gt; niche it&lt;br&gt;
still: flagged one should-have-assessed project, killed one adoption on&lt;br&gt;
licensing, mapped a peer's opposite architecture, and revealed three upstream&lt;br&gt;
dependencies we hadn't consciously chosen.&lt;/p&gt;

&lt;p&gt;So it's now a checklist step, not a retrospective:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;New build domain?
1. Scan first: GitHub topic search + MCP directories + the
   SignalK plugin registry (appstore), BEFORE the design doc.
2. For each hit: license, last commit, maintainer posture,
   philosophy fit (operational vs informational).
3. Record the verdict per alternative — adopt / borrow the
   design / pattern-reference-only / novel, with reasons.
4. Re-run the audit when you know the ecosystem better.
   (Verdicts age: one "stalled" project on our June map
   shipped new releases within a month.)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step 3 is the part that compounds: the recorded verdicts are what let us&lt;br&gt;
answer "how much did we rebuild?" with a table instead of a feeling — and&lt;br&gt;
they're what this post is made of.&lt;/p&gt;

&lt;p&gt;Everything above ships from &lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;github.com/sailingnaturali&lt;/a&gt;,&lt;br&gt;
built for an all-electric sailboat's agent stack — if you're building in the&lt;br&gt;
marine-MCP space, the map says there's plenty of open water.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Related:&lt;/em&gt;&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-adopt-vs-build-ships-log-signalk-logbook-mcp%20"&gt;adopt vs build: the ship's logbook&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-marine-weather-mcp-buoy-ground-truth-ndbc-spec-swell-wind-waves%20"&gt;the NDBC buoy reality-check how-to&lt;/a&gt; ·&lt;br&gt;
&lt;a href="https://dev.to%20post_url%202026-06-06-signalk-mcp-named-tools-vs-execute-code-token-efficiency-voice-agent%20"&gt;named tools vs execute-code for voice agents&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>signalk</category>
      <category>marine</category>
      <category>ai</category>
    </item>
    <item>
      <title>Two tidal libraries disagreed: diff their internals, not their outputs</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Tue, 21 Jul 2026 13:37:24 +0000</pubDate>
      <link>https://dev.to/clarkbw--/two-tidal-libraries-disagreed-diff-their-internals-not-their-outputs-klg</link>
      <guid>https://dev.to/clarkbw--/two-tidal-libraries-disagreed-diff-their-internals-not-their-outputs-klg</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Two tide libraries gave different answers and bisecting by ablation found nothing, because the difference was spread thin across a dozen small terms. Both libraries expose their own per-constituent astronomical argument and nodal factors — comparing &lt;em&gt;those&lt;/em&gt; instead of the predictions localized it in one probe. &lt;strong&gt;Jump to the fix.&lt;/strong&gt; Second lesson, nearly as valuable: ask which &lt;em&gt;regime&lt;/em&gt; your discrepancy was measured in. Ours was measured without re-fitting, and the shipped pipeline re-fits, which made the headline number 4x too scary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have a small tool, &lt;a href="https://github.com/sailingnaturali/chs-constituents" rel="noopener noreferrer"&gt;&lt;code&gt;chs-constituents&lt;/code&gt;&lt;/a&gt;, that fits tidal-&lt;strong&gt;current&lt;/strong&gt; harmonic constituents from Canadian Hydrographic Service published predictions so a chartplotter or SignalK server can predict currents offline. It started in Python on &lt;a href="https://github.com/wesleybowman/UTide" rel="noopener noreferrer"&gt;utide&lt;/a&gt;. I ported it to TypeScript on &lt;a href="https://github.com/openwatersio/neaps" rel="noopener noreferrer"&gt;&lt;code&gt;@neaps/tide-predictor&lt;/code&gt;&lt;/a&gt;, kept the Python running alongside as an oracle, and validated one against the other before deleting the Python.&lt;/p&gt;

&lt;p&gt;Most of the port validated clean. One number would not.&lt;/p&gt;

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

&lt;p&gt;Nineteen Salish Sea current stations, a 180-day training window, validated out-of-sample against CHS's own published events for the following week. Both implementations got byte-identical input samples, so any difference is the fit, not the fetch.&lt;/p&gt;

&lt;p&gt;Extremum timing — peak flood, peak ebb — came out at parity everywhere. Worst difference in either direction was 1.0 minute, which is the resolution of the measure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Slack&lt;/strong&gt; did not. Median absolute slack-timing error against ground truth, minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;station              TS (neaps)   Python (utide)
Juan de Fuca East          17.7              9.5
Race Passage                8.1              5.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slack water is where a current prediction earns its keep — it's the number you plan a transit around. Two stations, both weak and slow-reversing, both worse in the port.&lt;/p&gt;

&lt;p&gt;Two hypotheses died fast:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;mean/offset term  → agrees to 4 decimal places at every station. Not it.
basis degeneracy  → refit both at 240 days (clears more Rayleigh pairs).
                    Gap unchanged. Not it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That leaves the synthesis engine itself. To prove it, feed the &lt;strong&gt;Python's own fitted constituents&lt;/strong&gt; through neaps' synthesis and compare against the Python's own predicted events. Now nothing differs except the engine:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;median slack difference, utide constituents → neaps synthesis
  Juan de Fuca East   24.6 min
  Tillicum Bridge      8.1 min
  Race Passage         5.0 min
  everywhere else     &amp;lt; 2.5 min
  extrema             ≤ 6 min throughout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;24.6 minutes between two respectable open-source tide engines given the same constituents. Which one is wrong, and where?&lt;/p&gt;

&lt;h2&gt;
  
  
  What I tried, and why it failed
&lt;/h2&gt;

&lt;p&gt;The obvious move is to bisect: the disagreement lives in some constituent, so drop them one at a time and watch the number move.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Juan de Fuca East, median slack difference after dropping one constituent

  baseline (all)   24.6 min
  drop K2          19.4 min   ← largest single effect
  drop 2N2         ~22   min
  drop M3          ~22   min
  drop MU2, MSF, MF, J1, T2 each moved it by less than 5 min
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing is guilty. K2 is the biggest single contributor and removing it entirely accounts for a fifth of the gap. There is no culprit constituent because &lt;strong&gt;there is no culprit constituent&lt;/strong&gt; — the disagreement is spread across 2N2, M2, MM, J1 and N2 with no dominant term.&lt;/p&gt;

&lt;p&gt;This is the failure mode worth naming. &lt;strong&gt;Ablation is a bisection, and bisection assumes the thing you're hunting is localized.&lt;/strong&gt; When a discrepancy is a sum of many small contributions, every removal moves the number a little and nothing ever looks responsible. You can run that loop for a long time and learn only that you've run it.&lt;/p&gt;

&lt;p&gt;I also had two known convention differences on file and neither explained the magnitude alone:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;utide encodes M3's 180° offset as &lt;code&gt;semi=-0.5&lt;/code&gt;; neaps encodes it as an extended-Doodson digit 7.&lt;/li&gt;
&lt;li&gt;2N2 phase differed by a consistent ~5°.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Two suspicious details, one dead-end search, and a 24.6-minute number nobody could source.&lt;/p&gt;

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

&lt;p&gt;Stop comparing outputs. Compare &lt;strong&gt;internals&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A tide engine's prediction is a sum over constituents of&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;v(t) = Z0 + SUM_j  f_j(t) * A_j * cos( w_j*t + V0_j + u_j(t) - phi_j )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;A&lt;/code&gt; and &lt;code&gt;phi&lt;/code&gt; are fitted. Everything else — the speed &lt;code&gt;w&lt;/code&gt;, the equilibrium/astronomical argument &lt;code&gt;V0&lt;/code&gt;, and the nodal correction pair &lt;code&gt;f&lt;/code&gt; (amplitude factor) and &lt;code&gt;u&lt;/code&gt; (phase offset) — is &lt;em&gt;astronomy the library computes for you&lt;/em&gt;. Both libraries expose all of it. So instead of diffing 24.6 minutes of predicted slack, diff the four intermediate values, per constituent, at one instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;neaps side&lt;/strong&gt; — &lt;code&gt;constituents[name].value(astro(t))&lt;/code&gt; gives V in degrees, &lt;code&gt;.correction(astro(t))&lt;/code&gt; gives &lt;code&gt;{f, u}&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;constituents&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@neaps/tide-predictor&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;NAMES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;M2&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="s2"&gt;S2&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="s2"&gt;N2&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="s2"&gt;2N2&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="s2"&gt;MU2&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="s2"&gt;NU2&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="s2"&gt;K2&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="s2"&gt;T2&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="s2"&gt;K1&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="s2"&gt;O1&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="s2"&gt;P1&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="s2"&gt;Q1&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="s2"&gt;J1&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="s2"&gt;M3&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="s2"&gt;MM&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="s2"&gt;MSF&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="s2"&gt;MF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&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;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2025-12-28T00:00:00Z&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;astro&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;NAMES&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;c&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;constituents&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;f&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;correction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;speed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;        &lt;span class="c1"&gt;// deg/hour&lt;/span&gt;
    &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;value&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;     &lt;span class="c1"&gt;// V, degrees&lt;/span&gt;
    &lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;6&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="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;              &lt;span class="c1"&gt;// degrees&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&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;&lt;strong&gt;utide side&lt;/strong&gt; — &lt;code&gt;FUV&lt;/code&gt; returns all three at once. Note the units: utide returns &lt;code&gt;U&lt;/code&gt; and &lt;code&gt;V&lt;/code&gt; in &lt;strong&gt;cycles&lt;/strong&gt;, and &lt;code&gt;freq&lt;/code&gt; in cycles/hour, so multiply by 360 to land in neaps' degrees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;utide&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;ut_constants&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;utide.harmonics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FUV&lt;/span&gt;

&lt;span class="n"&gt;NAMES&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;M2&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;S2&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;N2&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;2N2&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;MU2&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;NU2&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;K2&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;T2&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;K1&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;O1&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;P1&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;Q1&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;J1&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;M3&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;MM&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;MSF&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;MF&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;table&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ut_constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;const&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="n"&gt;lind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;NAMES&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;datenum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;utide counts days from the proleptic year 0.

    Do NOT reach for matplotlib.dates.date2num here — it changed epoch in
    matplotlib 3.3 and now returns days since 1970-01-01, which is 719163
    days short. FUV accepts it happily and prints a plausible, entirely
    wrong table.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toordinal&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="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hour&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;minute&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mf"&gt;86400.0&lt;/span&gt;


&lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;datenum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2025&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="mi"&gt;28&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="c1"&gt;# ngflgs = [nodsatlint, nodsatnone, gwchlint, gwchnone] — all zero means
# exact nodal/satellite corrections and exact Greenwich phase, no shortcuts.
&lt;/span&gt;&lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;U&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FUV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&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="n"&gt;lind&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;48.5&lt;/span&gt;&lt;span class="p"&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="mi"&gt;0&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;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NAMES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\t&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="n"&gt;name&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;ut_constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;const&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;freq&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;lind&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="n"&gt;f&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="c1"&gt;# deg/hour
&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;V&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&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="c1"&gt;# V, degrees
&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;F&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="n"&gt;f&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="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;U&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="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;360&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&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="c1"&gt;# u, degrees
&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;Validate the mapping before you trust the diff.&lt;/strong&gt; The first run produced utide values that looked wrong, and the honest first question is not "which library is broken" but "is my comparison harness lining up the same waves?" A name-keyed lookup across two independent constituent tables is exactly the place an off-by-one index bug hides, and it will happily produce a beautiful, entirely fictional disagreement. So check identity by a quantity that doesn't depend on your mapping being right — &lt;strong&gt;frequency&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;speed match, neaps vs utide, all 17 constituents:  &amp;lt;= 1e-7 deg/hour
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same waves. Now the diff means something — and the frequency match doubles as a check on the unit conversion, since a wrong factor on utide's cycles would have thrown the speeds off by exactly that factor.&lt;/p&gt;

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

&lt;p&gt;Three findings, in the order they landed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It is not the astronomical argument.&lt;/strong&gt; &lt;code&gt;V&lt;/code&gt; agrees between the engines to &lt;strong&gt;0.00°&lt;/strong&gt; on every constituent in the basis. The single exception is M3's known 180° offset, which lives in V, not u. Whatever the 24.6 minutes is, the two libraries agree exactly on where the sun and moon are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It is not the linear-phase shortcut either&lt;/strong&gt; — a hypothesis the original ablation never got around to testing. neaps evaluates &lt;code&gt;V&lt;/code&gt; once at the start of the series and carries phase forward as &lt;code&gt;V0 + wt&lt;/code&gt; rather than re-evaluating the astronomy at every sample. That's an approximation, and approximations are suspects. So check whether neaps' own tabulated &lt;code&gt;speed&lt;/code&gt; actually equals the time derivative of its own &lt;code&gt;value()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;d/dt value()  vs  tabulated speed:       agree to ~1e-8 deg/hour
accumulated phase drift over 187 days:   &amp;lt; 0.0002 deg, every constituent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sound. Testing a hypothesis and &lt;em&gt;clearing&lt;/em&gt; it is real progress even though it moves no numbers — it permanently removes a thing you'd otherwise keep circling back to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. It is the nodal corrections, and it is a convention difference, not a bug in either library.&lt;/strong&gt; The nodal pair &lt;code&gt;f&lt;/code&gt;/&lt;code&gt;u&lt;/code&gt; corrects for the 18.6-year regression of the lunar nodes, and there are two schools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;neaps&lt;/strong&gt; applies &lt;strong&gt;grouped&lt;/strong&gt; factors: it groups constituents by their &lt;a href="https://iho.int/" rel="noopener noreferrer"&gt;IHO Annex B&lt;/a&gt; nodal-correction code, and constituents sharing a code share &lt;code&gt;f&lt;/code&gt;/&lt;code&gt;u&lt;/code&gt; &lt;em&gt;exactly&lt;/em&gt; — M2, N2, 2N2, MU2 and NU2 agree to nine decimal places. O1 and Q1 share another code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;utide&lt;/strong&gt; applies &lt;strong&gt;Foreman's satellite-derived per-constituent&lt;/strong&gt; factors, where each constituent gets its own, derived from the satellite lines around each main line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The largest split:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;constituent     f (neaps)     f (utide)     utide vs neaps
2N2             0.965         1.110         +15%   (+ ~3.6 deg in u)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;utide's 2N2 nodal factor comes out &lt;strong&gt;15% higher&lt;/strong&gt; than neaps'.&lt;/p&gt;

&lt;p&gt;These are also the values &lt;strong&gt;at one instant&lt;/strong&gt; — 2025-12-28, the validation-window date both probes above use. &lt;code&gt;f&lt;/code&gt; and &lt;code&gt;u&lt;/code&gt; are not constants; they track the 18.6-year regression of the lunar nodes, which is the entire reason they exist. Evaluate the same comparison six months earlier and 2N2 reads 0.964 against 1.075, +11.5% rather than +15%. So pin your date when you quote a nodal factor, and pin the &lt;em&gt;same&lt;/em&gt; date on both sides of a cross-library comparison — otherwise you are diffing the calendar, not the libraries.&lt;/p&gt;

&lt;p&gt;neaps' 0.965 for 2N2 is just M2's value, because in the grouped scheme 2N2 &lt;em&gt;is&lt;/em&gt; in M2's group. utide computes 2N2's own. Neither is wrong: grouping is the classical approximation — the tradition &lt;a href="https://tidesandcurrents.noaa.gov/publications/SpecialPubNo98.pdf" rel="noopener noreferrer"&gt;Schureman&lt;/a&gt; codified and the one IHO's tables carry forward — and satellite-Foreman is the refined one. (Historical nicety for anyone in these waters: &lt;a href="https://waves-vagues.dfo-mpo.gc.ca/Library/54866.pdf" rel="noopener noreferrer"&gt;Foreman's manual&lt;/a&gt; came out of the Institute of Ocean Sciences at Patricia Bay, which is about ten miles from the stations that surfaced this.)&lt;/p&gt;

&lt;p&gt;And that is exactly why the ablation was doomed. The disagreement is a grouped-vs-per-constituent scheme difference, so it touches &lt;em&gt;every&lt;/em&gt; constituent whose group has structure — a little bit each, no dominant term, nothing to bisect onto.&lt;/p&gt;

&lt;p&gt;One asymmetry does look like a genuine gap rather than a convention: &lt;strong&gt;utide returns &lt;code&gt;f = 1.0&lt;/code&gt; and &lt;code&gt;u = 0.0&lt;/code&gt; exactly for MM, MSF and MF&lt;/strong&gt; — no nodal correction at all on the long-period constituents, where neaps applies real factors. That is the one place I'd say neaps is more likely correct. It barely matters for currents, where those amplitudes are small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the default before you blame the library
&lt;/h2&gt;

&lt;p&gt;Be careful with the label here, because I nearly got it wrong in a way that would have been quietly wrong forever.&lt;/p&gt;

&lt;p&gt;neaps ships &lt;strong&gt;two&lt;/strong&gt; fundamentals sets, &lt;code&gt;iho&lt;/code&gt; and &lt;code&gt;schureman&lt;/code&gt;, and they are selectable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;createTidePredictor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;constituents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;nodeCorrections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schureman&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 default is &lt;code&gt;iho&lt;/code&gt; — &lt;code&gt;correction(astro, fundamentals = ...)&lt;/code&gt; resolves to the IHO set for any falsy argument — and &lt;code&gt;iho&lt;/code&gt; is what the fit path and every number above actually used. I had originally written this up as "neaps applies Schureman factors," which is a reasonable guess from the fact that a &lt;code&gt;schureman&lt;/code&gt; export exists, and it is wrong.&lt;/p&gt;

&lt;p&gt;It matters, because the two sets are not interchangeable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;switching neaps from iho to schureman
  J1   moves 7.2%
  MF   moves 5.5%
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sit that next to the thing this whole post is about. &lt;strong&gt;J1 differs by 7.2% between neaps' own two modes, against 1.5% between neaps and utide.&lt;/strong&gt; The intra-library spread is wider than the inter-library spread I spent a day characterizing. "Which nodal convention" turns out to be a bigger lever than "which library" — and if you attribute a discrepancy to a library without checking which convention it defaulted to, you can produce a confident, well-measured, completely misattributed finding.&lt;/p&gt;

&lt;p&gt;The only reason I caught it is that the default was spelled out in the type signature. Read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The regime question — and why 24.6 minutes was 4x too scary
&lt;/h2&gt;

&lt;p&gt;Here's the part that changed the verdict.&lt;/p&gt;

&lt;p&gt;The 24.6-minute figure was measured by feeding utide's constituents into neaps' synthesis. &lt;strong&gt;Nothing is re-fitted in that setup&lt;/strong&gt;, so the entire f/u disagreement lands directly in the output.&lt;/p&gt;

&lt;p&gt;The shipped pipeline does not work that way. It fits &lt;em&gt;and&lt;/em&gt; synthesizes in neaps. A least-squares fit solves for amplitude and phase against the same &lt;code&gt;f&lt;/code&gt;/&lt;code&gt;u&lt;/code&gt; the synthesis will later apply — so any &lt;strong&gt;constant&lt;/strong&gt; offset in &lt;code&gt;f&lt;/code&gt;/&lt;code&gt;u&lt;/code&gt; is absorbed into the fitted &lt;code&gt;A&lt;/code&gt; and &lt;code&gt;phi&lt;/code&gt; and cancels exactly. What survives is only the &lt;em&gt;drift&lt;/em&gt; in &lt;code&gt;f&lt;/code&gt;/&lt;code&gt;u&lt;/code&gt; across the training-to-validation span.&lt;/p&gt;

&lt;p&gt;Weighted by fitted amplitude at Dodd Narrows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;raw cross-engine  (the regime 24.6 min was measured in)   2.9%  of M2
post-fit residual (the regime that actually ships)        0.72% of M2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.72% of M2 is roughly 0.85 minutes at an ordinary zero crossing. Multiply by the ~7x amplification a slow reversal gives — a weak, lazily-reversing station spends a long time near zero, so a small velocity error moves the crossing a long way in time — and you land on the 8.2-minute slack gap actually observed at Juan de Fuca East. &lt;strong&gt;Mechanism and magnitude both check out, and the headline number overstated the shipped impact by about 4x.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Same logic retires M3. Its scary-looking 180° convention difference drifts by 0.03° over the span — it is &lt;em&gt;constant&lt;/em&gt;, so the fit absorbs it completely. Pure bookkeeping, zero effect on output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict: not worth fixing.&lt;/strong&gt; The residual sits below the CHS validation floor and neither engine wins against ground truth — utide is closer at Juan de Fuca East and Race Passage, neaps is closer at Blackney, Tillicum, Sechelt and Hole in the Wall. Adopting Foreman factors in neaps would be real work with no evidence it improves anything. The whole stack is neaps-based, so agreeing with neaps is the more useful consistency. The characterization goes upstream as a documentation note, not a defect report.&lt;/p&gt;

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

&lt;p&gt;Five transferable pieces, none of them about tides.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Ablation cannot find a distributed cause.&lt;/strong&gt; Dropping one input at a time is a bisection, and bisection needs the target to be localized. If the true cause is a scheme-level difference touching many terms, ablation returns a flat, uninformative gradient — every removal moves the number a little, nothing is guilty, and you conclude "no single X explains it" without ever learning what does. Recognize that flat gradient as a &lt;em&gt;signal&lt;/em&gt;, not a stalemate: it's telling you to stop bisecting.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. When two implementations disagree, diff their intermediates.&lt;/strong&gt; Both of these libraries expose their internal astronomy. Comparing V, f and u took one probe and split the problem into "identical" and "the entire difference is here" in a single step, after the output-space search had run out of ideas. Almost any pair of libraries computing the same thing has this surface — parsers expose tokens, ML frameworks expose layer activations, numeric code exposes every stage. Diffing outputs tells you &lt;em&gt;that&lt;/em&gt; you disagree; diffing intermediates tells you &lt;em&gt;where&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Validate the identity mapping before trusting a cross-library comparison.&lt;/strong&gt; A name-to-index lookup between two independent tables is a great place for a silent bug, and it produces a diff that looks like a real finding. Verify with something the mapping can't fake — here, matching frequencies to 1e-7 °/h.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Check which convention your library defaulted to before blaming the library.&lt;/strong&gt; neaps' own two nodal sets differ by more on J1 (7.2%) than neaps and utide differ (1.5%). A library is not one implementation — it's a default plus a set of options, and "library A disagrees with library B" is only meaningful once you know which mode each one was in. The default is usually right there in the type signature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Ask which regime your number was measured in.&lt;/strong&gt; A difference measured in an artificial setup is not the difference your users see if the shipped path is different. Ours was measured with no re-fitting; the pipeline re-fits, and a fit absorbs any constant offset in a correction factor. That distinction was worth a 4x correction to the severity, and it's the difference between "chase this for a week" and "document it and move on."&lt;/p&gt;

&lt;p&gt;One practical bonus: the event-comparison harness that produced the 24.6 minutes can never live in that repo, because it needs CHS-derived data and the &lt;a href="https://tides.gc.ca/en/licence-agreement" rel="noopener noreferrer"&gt;CHS licence&lt;/a&gt; forbids redistributing it. The f/u probe above has that property inverted — &lt;strong&gt;it uses no station data at all&lt;/strong&gt;, only the two libraries' own astronomy. Both snippets run standalone. That's a nice general property of internals-diffing: it's usually cheaper to reproduce and share than the output comparison it replaces.&lt;/p&gt;

&lt;p&gt;I'm building an all-electric charter catamaran's software stack in the open, and offline current prediction for the Salish Sea's tidal gates is a piece of it — the pipeline is &lt;a href="https://github.com/sailingnaturali/chs-constituents" rel="noopener noreferrer"&gt;&lt;code&gt;chs-constituents&lt;/code&gt;&lt;/a&gt;, and the SignalK plugin that consumes it 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;

&lt;p&gt;&lt;strong&gt;References:&lt;/strong&gt; &lt;a href="https://tidesandcurrents.noaa.gov/publications/SpecialPubNo98.pdf" rel="noopener noreferrer"&gt;Schureman, &lt;em&gt;Manual of Harmonic Analysis and Prediction of Tides&lt;/em&gt;, Special Publication 98&lt;/a&gt; (1958) · &lt;a href="https://waves-vagues.dfo-mpo.gc.ca/Library/54866.pdf" rel="noopener noreferrer"&gt;Foreman, &lt;em&gt;Manual for Tidal Heights Analysis and Prediction&lt;/em&gt;, Pacific Marine Science Report 77-10&lt;/a&gt; · &lt;a href="https://github.com/wesleybowman/UTide" rel="noopener noreferrer"&gt;UTide (Python)&lt;/a&gt; · &lt;a href="https://github.com/openwatersio/neaps" rel="noopener noreferrer"&gt;neaps&lt;/a&gt;&lt;/p&gt;

</description>
      <category>tides</category>
      <category>marine</category>
      <category>typescript</category>
      <category>python</category>
    </item>
    <item>
      <title>The commented-out code was a to-do, not a bug: finishing waves in SignalK's Open-Meteo plugin</title>
      <dc:creator>Bryan Clark</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:04:24 +0000</pubDate>
      <link>https://dev.to/clarkbw--/the-commented-out-code-was-a-to-do-not-a-bug-finishing-waves-in-signalks-open-meteo-plugin-2en6</link>
      <guid>https://dev.to/clarkbw--/the-commented-out-code-was-a-to-do-not-a-bug-finishing-waves-in-signalks-open-meteo-plugin-2en6</guid>
      <description>&lt;p&gt;Our boat's weather comes from a chain we didn't fully own. &lt;a href="https://open-meteo.com" rel="noopener noreferrer"&gt;Open-Meteo&lt;/a&gt;&lt;br&gt;
serves a free forecast; the &lt;a href="https://github.com/SignalK/openmeteo-provider-plugin" rel="noopener noreferrer"&gt;SignalK Open-Meteo provider plugin&lt;/a&gt;&lt;br&gt;
pulls it into the boat's own weather API at &lt;code&gt;/signalk/v2/api/weather/forecasts/point&lt;/code&gt;;&lt;br&gt;
and our &lt;a href="https://github.com/sailingnaturali/weather-mcp" rel="noopener noreferrer"&gt;weather-mcp&lt;/a&gt; reads &lt;em&gt;that&lt;/em&gt; — so the&lt;br&gt;
voice agent gets sea state from the boat's canonical weather surface instead of every tool&lt;br&gt;
hitting the internet on its own.&lt;/p&gt;

&lt;p&gt;Except the sea state never arrived.&lt;/p&gt;
&lt;h2&gt;
  
  
  A consumer with nothing to consume
&lt;/h2&gt;

&lt;p&gt;weather-mcp has a &lt;code&gt;signalk&lt;/code&gt; provider whose whole job is to map the SignalK weather response&lt;br&gt;
into our forecast model. It was written to read the &lt;code&gt;water&lt;/code&gt; block:&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;water&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;wd&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;water&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="c1"&gt;# ...
&lt;/span&gt;&lt;span class="n"&gt;swell&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_wave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;water&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;swellHeight&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;swellDirection&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;swellPeriod&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;combined_wave&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;_wave&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;water&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;waveSignificantHeight&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;waveDirection&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;wavePeriod&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;p&gt;That &lt;code&gt;or {}&lt;/code&gt; is doing a lot of quiet work. Every forecast hour came back with no &lt;code&gt;water&lt;/code&gt; key&lt;br&gt;
at all, so the map produced &lt;code&gt;None&lt;/code&gt; for every wave field, and the provider dutifully reported&lt;br&gt;
a forecast with wind and pressure but no waves. No error, no warning — just a hole shaped&lt;br&gt;
exactly like the data we wanted.&lt;/p&gt;

&lt;p&gt;The hole wasn't in our code. It was upstream: the plugin's &lt;code&gt;getForecasts&lt;/code&gt; returned wind and&lt;br&gt;
atmospheric fields only. The &lt;code&gt;WeatherData.water&lt;/code&gt; fields were never populated. We had built&lt;br&gt;
the consumer before the producer existed.&lt;/p&gt;
&lt;h2&gt;
  
  
  Chesterton's fence: why was the marine code commented out?
&lt;/h2&gt;

&lt;p&gt;The obvious move when you find the gap is to fork the plugin and add the marine fetch. The&lt;br&gt;
less obvious — and correct — move is to ask &lt;em&gt;why it isn't there already&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It turned out the plumbing mostly &lt;strong&gt;was&lt;/strong&gt; there: &lt;code&gt;getMarineUrl&lt;/code&gt; existed, and a &lt;code&gt;water.*&lt;/code&gt;&lt;br&gt;
mapping sat in the source, commented out. Easy to read that as "broken, disabled in a&lt;br&gt;
hurry." Git history said otherwise. The marine URL builder had been fleshed out in a later&lt;br&gt;
commit, but the fetch, merge, and mapping around it were never wired — the marine path was&lt;br&gt;
&lt;strong&gt;unfinished from the initial scaffold, not broken and switched off&lt;/strong&gt;. That distinction&lt;br&gt;
changes everything: there was no regression to be careful around, just a to-do someone left&lt;br&gt;
in the open.&lt;/p&gt;

&lt;p&gt;The history surfaced one real constraint, though. Open-Meteo is free and rate-limited, and&lt;br&gt;
the plugin already carried &lt;a href="https://github.com/SignalK/openmeteo-provider-plugin/issues/3" rel="noopener noreferrer"&gt;scars from request-throttling&lt;/a&gt; —&lt;br&gt;
caching and request frequency were live concerns, not hypotheticals. Reintroducing a second&lt;br&gt;
network call per forecast naively was exactly the kind of thing that fence was guarding&lt;br&gt;
against. Knowing &lt;em&gt;that&lt;/em&gt; shaped the fix.&lt;/p&gt;
&lt;h2&gt;
  
  
  Finishing it
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/SignalK/openmeteo-provider-plugin/pull/7" rel="noopener noreferrer"&gt;PR&lt;/a&gt; is one file and three&lt;br&gt;
moves:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Merge the marine series.&lt;/strong&gt; Open-Meteo serves marine data from a separate subdomain&lt;br&gt;
(&lt;code&gt;marine-api.open-meteo.com&lt;/code&gt;, distinct from &lt;code&gt;api.open-meteo.com&lt;/code&gt;). We fetch it inside the&lt;br&gt;
same cache-miss path as the atmospheric forecast, so it's cached alongside it — one more&lt;br&gt;
request per &lt;em&gt;cache miss&lt;/em&gt;, not per &lt;em&gt;call&lt;/em&gt;, which is the number the throttling history actually&lt;br&gt;
cares about. And the marine fetch is optional: if it fails, the catch degrades to a&lt;br&gt;
wind-and-atmospheric forecast rather than failing the whole response. A boat with no wave&lt;br&gt;
data is fine; a boat with no forecast is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix the units the draft got wrong.&lt;/strong&gt; The commented-out mapping multiplied wave periods by&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;SignalK is an SI data model — periods are seconds, not milliseconds. The disabled
draft would have reported every swell period off by three orders of magnitude. This is the
case &lt;em&gt;for&lt;/em&gt; finishing commented code rather than just uncommenting it: the draft wasn't
correct-but-disabled, it was a sketch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Align the horizon.&lt;/strong&gt; The marine query defaulted to 8 hours; the hourly forecast defaults to&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;We bumped the marine default to match, so waves cover the same window as the wind.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Verified live against Boundary Pass — every forecast hour now carries &lt;code&gt;water&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"waveSignificantHeight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"waveDirection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;4.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"wavePeriod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;3.55&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"swellHeight"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;0.24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"swellDirection"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;3.72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"swellPeriod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;2.9&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;One caveat we documented rather than fought: the SignalK &lt;code&gt;water&lt;/code&gt; spec carries combined&lt;br&gt;
significant-wave height and swell, but no separate &lt;strong&gt;wind-wave&lt;/strong&gt; component. Open-Meteo's&lt;br&gt;
marine API has it; the SignalK surface has nowhere to put it. So a forecast sourced through&lt;br&gt;
SignalK reports combined-plus-swell, and our provider sets &lt;code&gt;wind_wave&lt;/code&gt; to &lt;code&gt;None&lt;/code&gt; on that&lt;br&gt;
path. A consumer that needs the wind-wave split goes direct to Open-Meteo. That's a spec&lt;br&gt;
limit, not a bug, and the right place for it is a comment in the mapper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: the loop closed without a fork
&lt;/h2&gt;

&lt;p&gt;The moment the PR merged, nothing in our codebase changed — and the thing we wanted started&lt;br&gt;
working. weather-mcp's SignalK provider had been reading &lt;code&gt;water.*&lt;/code&gt; all along; the upstream&lt;br&gt;
plugin simply started putting data there. The producer and the consumer live in different&lt;br&gt;
GitHub orgs, maintained by different people, and they met at a documented API contract.&lt;/p&gt;

&lt;p&gt;This is the &lt;a href="https://github.com/sailingnaturali" rel="noopener noreferrer"&gt;adopt-before-build rule&lt;/a&gt; playing out at the&lt;br&gt;
seam. We could have forked the plugin, or added a private marine fetch to weather-mcp and&lt;br&gt;
duplicated what Open-Meteo's plugin almost did. Either would have worked and both would have&lt;br&gt;
been ours to carry forever. Instead the gap got filled where it belonged — in the shared&lt;br&gt;
plugin every SignalK boat installs — and our side stayed a thin reader of a standard API.&lt;/p&gt;

&lt;p&gt;The cost was reading enough history to know the fence was a to-do and not a load-bearing&lt;br&gt;
wall. That's most of the work in contributing upstream: not the diff, but earning the right&lt;br&gt;
to make it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mcp</category>
      <category>signalk</category>
      <category>opensource</category>
    </item>
    <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>
  </channel>
</rss>
