Engineering

Engineering notes from building the AI ops layer behind Sailing Naturali — an all-electric charter catamaran run on a hybrid local/cloud agent stack: Home Assistant, SignalK, a fleet of open-source MCP servers, a local LLM, and live NMEA/marine data.

We post when something genuinely useful lands — a non-obvious fix, a dead-end worth saving you, a config that took more than one debug cycle. Code first, no fluff. What this is →

Posts

  • Drop the vendor station ID: licensing as a data-model constraint

    TL;DR@sailingnaturali/station-corrections 2.0.0 deleted providerId, the one field in a public MIT registry that pointed into a provider’s database. Consumers now join records by station name and resolve the provider’s opaque handle at runtime, under their own licence to that API. It cost a major version and reshaped four repos — and it’s a stronger control than a compliance note in a README, because the id can’t leak from a field that doesn’t exist. Jump to the fix.

  • Two alert lanes: the siren shouldn't depend on an LLM

    Don’t put a model in the path of the alarm that matters most. Split the notification router in two: alarm/emergency go straight to the Telegram Bot API from the SignalK plugin — no gateway, no model, no billing relationship — while alert/warn wake an agent turn that reads the vessel and writes one considered message. The lane that matters most then depends on the fewest components. Jump to the fix.

  • SignalK's two token classes: why plugin routes 401 your device token

    TL;DR — SignalK server has two token classes with different powers. A device token (Security → Access Requests) can write data — deltas, PUTs — but gets 401 Permission Denied on every /plugins/<id>/* and /skServer/* route, because those are admin-gated and a device token is readwrite, not admin. Plugin REST APIs need a user JWT from POST /signalk/v1/auth/login — and that JWT silently expires per the expiration field in security.json (the field the admin UI calls “Remember Me timeout”; the code falls back to 1h). Jump to the fix.

  • A tidal gate with no current station: deriving slack from a tide port

    TL;DR — Some tidal gates have no current station at all: the published knowledge is “slack is the reference tide port’s high water plus 25 minutes.” That is strictly less information than a fitted gate carries — no constituents, no flood/ebb axis, no speed — and the honest move is to model it as its own record type and carry the shortfall all the way to the agent’s answer rather than fabricating a zero. Jump to the fix.

  • Porting a tide engine to Swift with the original as the test oracle

    TL;DR — Porting numeric code by hand and eyeballing the output is how you ship plausible-looking wrong answers. Instead, treat the original implementation as a test oracle: scripts that run the reference and dump golden fixtures, one per algorithm layer, each ported layer gated on matching the oracle to 1e-6 before the next begins. And when the port passes, you’re still not done — the last gate is matching reality, not the reference. Repo: slackwater-engine.

  • plugins.allow is exclusive: the warning's suggested fix disabled 44 plugins

    A config warning told me, every single turn, that plugins.allow was empty — and helpfully printed the one-line fix. I pasted it onto the live box. Enabled plugins went 47 → 3, because plugins.allow is an exclusive allowlist over all plugins, including the ~46 that ship bundled. The two that mattered were the fallback model provider and bundle-mcp, the plugin every MCP tool hangs off. Reverted in a minute. The durable fix wasn’t code — it was a comment in the config recording why the warning stays unfixed. Jump to the fix.

  • Enabling Bash costs more context than seven MCP tool schemas

    Nine days ago I measured MCP tool schemas against a curl recipe with a tokenizer and got a near-tie. This time I ran the agent: four arms, same model, same 12 asks, only the delivery mechanism varying. MCP was the cheapest arm — 109,779 billed input tokens for a 12-ask conversation against 158,021 for curl. The reason is not subtle: turning on the Bash tool costs ~2,700 tokens of harness prompt, more than all seven MCP schemas combined (1,160). The thing that actually costs money is neither — it is eagerly loading eleven MCP servers into every turn. Jump to the numbers.

  • The A/B that measured nothing: three ways my agent experiment was invalid

    I set out to test whether an MCP tool should return a rendered sentence or raw SI numbers. The experiment was invalid three times, for three unrelated reasons: the tools had never been called (a gateway tool-policy gap), the two arms were byte-identical at the model (the gateway drops a tool’s text content when structuredContent is present), and once it genuinely measured, the new shape lost. What actually fixed the behaviour was deleting a stale paragraph in the workspace instructions that told the model to do the opposite. Jump to the fix.

  • A localeCompare in a build script minted different public URLs per machine

    TL;DRString.prototype.localeCompare with no locale argument uses whatever collation the runtime happens to have. If a sort ordered by it decides an identifier — a slug, a first-wins registry key, an index — that identifier is pinned to the machine, not to the repo. Sort by codepoint (a < b ? -1 : a > b ? 1 : 0) with an id tiebreak for anything a URL is built from, and keep localeCompare for what a human reads. Jump to the fix.

  • Bench your own workload before you switch LLM vendors

    TL;DR: Before migrating our voice boat-agent off Claude Sonnet, we ran the new GPT-5.6 tiers through the same tool-routing benchmark the incumbent runs: 13 real asks, 74 MCP tool schemas, scored on whether the model calls the right tools. GPT-5.6 scored 38.5% vs Sonnet’s 92.3% on our workload, under our integration constraints — so no migration. Jump to the numbers. The transferable part isn’t the score, it’s the method.

  • MCP server or a curl recipe in AGENTS.md? Measure the breakeven

    The current consensus is that MCP is a token tax and you should just tell the agent to curl the API from its AGENTS.md. I measured both, for the same capability, against the same server. The tool schemas cost +408 tokens standing and save ~340–420 tokens per read, so the breakeven is roughly one tool call per turn — and with prompt caching (measured, not assumed: a 0.175× effective multiplier across 3,782 real requests) it moves to one read every five or six turns. But the tokens are the easy half and they nearly cancel. What actually decided it was a minus sign. Jump to the breakeven formula.

  • A persistent-queue HTTP reporter for distress traffic over flaky internet

    TL;DR — Don’t fetch()-and-forget safety-relevant reports over marine internet. signalk-distress-core ships a generic persistent-queue HTTP reporter: every payload is appended to a JSONL file before 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.

  • Offline tidal currents from harmonic constituents — and when not to trust them

    TL;DRsignalk-currents 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 Neaps. But every fallback reading is labeled source: "harmonic", live: false, and — at constricted passes — unreliableForTransit: true, because harmonic slack timing at a tidal rapids can be off by tens of minutes. While the network is up, the plugin logs harmonic-vs-live discrepancies locally so the error is measured, not guessed. Jump to the build.

  • Running OpenClaw on a Raspberry Pi alongside SignalK

    Running OpenClaw — a self-hosted, multi-channel agent gateway — on a Raspberry Pi 5 next to SignalK, so you can DM the boat over Telegram and get live vessel readings back. The traps that cost me time are up front — the gateway.mode start-block, SI-unit conversion, no history without InfluxDB, and a Chesterton’s-fence with the ollama plugin — then the walkthrough, then what I found tuning the standing prompt down. Jump to the gotchas.

  • httpx async requests to a .local hostname time out on macOS

    TL;DR — On macOS, httpx.AsyncClient requests to a .local (mDNS/Bonjour) hostname raise httpx.ConnectTimeout even though the host is up and curl works instantly. getaddrinfo puts an IPv6 candidate first, and the async connect burns the whole connect timeout on that unroutable path before IPv4 gets a turn. Resolve the .local host to IPv4 yourself, once, at the URL boundary — the system resolver handles mDNS fine — and rewrite the URL. Jump to the fix.

  • Where a received distress call belongs in the SignalK data model

    TL;DR — a distress alert heard from another vessel is not your boat’s own alarm state, so don’t raise it as plain notifications.* on self. The convention we landed on (signalk-dsc v0.9.0, signalk-ais-distress v0.5.0): raise the self-side alarm under notifications.received.* (“received about another vessel”), and also write the state record into the source vessel’s own contextvessels.<mmsi>.notifications.mob for MOB, notifications.distress for SART/EPIRB. Jump to the convention.

  • Shipping Canadian CHS station data without redistributing the licensed file

  • Two tidal libraries disagreed: diff their internals, not their outputs

    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 those instead of the predictions localized it in one probe. Jump to the fix. Second lesson, nearly as valuable: ask which regime 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.

  • The marine SignalK + MCP landscape: what exists, what's actually novel

    TL;DR — We shipped seven marine MCP servers, then ran the ecosystem scan we should have run first. The good news: the niche is sparse, and the capabilities we bet on (NDBC buoy reality-check, tidal currents, COLREGs rule text, anchorage comfort ranking) turned out to have no prior art anywhere in the MCP ecosystem. The bad news: a one-evening scan would still have changed several decisions. Jump to the map.

  • Two false walls in NOAA's currents API: empty constituents and "not available"

    This one started with a snap judgment, and the snap judgment was wrong in the most useful way. I was looking at XTide, the reference-grade open-source tide and current predictor, and my first reaction was: this website looks like it was built in 1998, I wouldn’t trust something this poorly put together. That reaction is unfair — XTide’s data is impeccable, and the crustiness of a maintainer’s homepage tells you nothing about the quality of the harmonic constants underneath it. But being wrong about XTide’s site pushed me to the right question about XTide’s role: do I even need to harvest XTide’s data, or can I go straight to the source it ultimately derives from — NOAA? Chasing that question cost me two full days walking into walls that both looked like the end of the road. Neither was.

  • How I proved a from-scratch harmonic tide engine is accurate

    You wrote a tide engine. It computes a number: 1.42 m at 3:10 PM. How do you know that number is right? You can’t measure the future ocean. You can’t diff against “the truth” because the truth is a prediction too. This is the whole problem with a from-scratch harmonic tide predictor — the output looks authoritative the day you first run it, and authoritative-looking is not the same as correct.

  • The commented-out code was a to-do, not a bug: finishing waves in SignalK's Open-Meteo plugin

    Our boat’s weather comes from a chain we didn’t fully own. Open-Meteo serves a free forecast; the SignalK Open-Meteo provider plugin pulls it into the boat’s own weather API at /signalk/v2/api/weather/forecasts/point; and our weather-mcp reads that — so the voice agent gets sea state from the boat’s canonical weather surface instead of every tool hitting the internet on its own.

  • When AI agents write the ship's log, record who wrote what

    TL;DR — When humans, vessel automation, and an AI agent all write the same logbook, you need per-entry provenance. We added an optional origin: manual | auto | agent field upstream in signalk-logbook (PR #88, released in 0.11.0), made consumers derive it from author conventions for legacy entries, and put the EU’s recommended AI label on agent-written lines. Jump to the design.

  • Tide predictions read higher than the tables? Suspect the datum

    TL;DR — If a harmonic tide prediction disagrees with the official tide tables by a constant offset at a station, the algorithm is fine; the two heights are measured from different zeros. Canadian chart datum is not LAT — at CHS stations it sits up to ~0.43 m above it, and the per-station offset is published in the CHS station /metadata API. Jump to the fix.

  • Your remote coding agent has the repo, not the machine — Tailscale is the missing half

    You can now run a coding agent as a remote session — 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.

  • The NMEA 2000 paradox has an open-source answer

    Galvanic Works published The NMEA 2000 Paradox — a sharp problem statement about why boats drowning in data still fail their owners: identical beeps for everything, humans as the monitoring system, alerts that collapse under stress, and vendor lock-in. I agree with essentially all of it. This post is the other half: each failure mode they name, next to the open-source code that already addresses it.

  • MQTT value_template doesn't filter: our wait fired on every message

    TL;DR: An MQTT trigger’s value_template is a payload transformer, not a filter — with no payload: key the trigger fires on every message on the topic. And script variables aren’t in scope in the per-message render, so a trigger-level match on a runtime variable isn’t expressible at all. Fire on everything and loop with repeat/until instead — jump to the fix.

  • Monitor the delivery path, not just the alarm

    We built a zero-dependency SignalK→ntfy relay so a man-overboard, low-battery, or depth alarm reaches a phone even when nobody’s at the chartplotter. It worked. Then, a month later, it silently stopped working, and we found out by accident — while smoke-testing an entirely different plugin, this line scrolled past in the SignalK server log:

  • A hands-free npm publish pipeline for SignalK plugins

    Publishing a SignalK plugin to npm once is easy: npm publish, type your OTP, done. Keeping eight of them shipping hands-free — each one auto-published on a tag, cross-tested on five platforms, and scoring 100 in the SignalK plugin registry — is a different problem, and it’s the one we keep seeing people stall on. This is the whole pipeline we run for the @sailingnaturali/* plugins, end to end.

  • When MCP tools break, isolate the server before blaming the agent

    You add a new MCP server, or a new tool to an existing one. You ask the agent to use it and it says the tool isn’t available — or worse, it confidently reports that “only the HTTP server is connected” and the new stdio servers aren’t live. The instinct is to blame the MCP server or bounce the runtime. Resist it. The agent’s narration about its own toolset is not ground truth, and a malformed test invocation will happily manufacture a fake “MCP unreachable” symptom. Here’s the broke → tried → fixed of a debugging session that burned time on exactly this.

  • How LLM agents confabulate infrastructure and data provenance

    Give an agent a tool that returns a number and ask it a question, and it will often answer with the number plus a story: which machine is running the service, whether that service is healthy, whether the reading is “live” or “from a test rig,” whether the thing being measured is on or offline. None of the story came from a tool. The model made it up — confidently, in the same flat voice it uses for the parts it actually observed.

  • An MCP 404 trips the circuit breaker and drops valid reads

    An agent intermittently “loses” a tool. It reads a sensor fine ten times, then flatly claims the value is unavailable — for a path you can see returning data in the upstream server’s own UI. Restart the session and it works again, for a while. The cause isn’t the tool and isn’t the server: it’s a raise_for_status() in the HTTP client turning a perfectly normal 404 (“not published”) into a tool failure, and a burst of those tripping the agent runtime’s consecutive-failure circuit breaker — which then suppresses the valid calls queued behind them.

  • Logging VHF DSC distress calls in SignalK (PGN 129808)

    A DSC distress alert is the most important packet a marine VHF will ever hand you, and it’s structured data. When a vessel hits the red button, its radio transmits a digital selective calling burst on channel 70: format specifier, the sender’s MMSI, category, nature of distress, position, and UTC time — encoded per ITU-R M.493. A DSC-equipped radio that hears it re-emits it to your network, as $--DSC/$--DSE sentences on NMEA 0183 or as PGN 129808 on NMEA 2000.

  • Why your agent ignores its skill body but obeys the system prompt

    You edit a skill’s prompt to fix a behaviour. You confirm the change is in the file the runtime loads. You restart. The agent does the old thing anyway. Not sometimes — reliably, for most queries.

  • Why Claude won't transcribe your PDF — and what to do instead

    We needed a public, machine-readable vault of the navigation rules of the road — COLREGS rule text — to back an MCP server for a boat agent. The obvious plan: hand Claude the USCG Navigation Rules and Regulations Handbook PDF and ask it to transcribe each rule into a clean markdown file. The text is a US Government work in the public domain. Free to copy, free to redistribute.

  • Why we kept named MCP tools despite a 96% token saving

    The boat-agent stack here runs on a prime directive: if there’s something usable out there, improve it; build our own only as a last resort. So when we needed a SignalK MCP server, the honest first move wasn’t to write one — it was to evaluate the one that already exists.

  • Why generic weather MCPs fail for marine navigation (use NDBC buoys)

    We run a prime directive on this stack: if a usable tool already exists, improve it; build our own only as a last resort, and when you keep your own, record why each alternative failed. This post is that audit for weather-mcp — a marine-weather MCP server — against the weather-MCP ecosystem, and the one capability change that fell out of it.

  • Discrete MCP tools vs execute_code: when each wins

    When we wanted our boat agents to read SignalK — wind, position, battery, depth — over MCP, there was already a capable server for it: VesselSense/signalk-mcp-server (TypeScript, MIT). It’s well built. Our prime directive says use existing tools before building your own, and we take that seriously — our ship’s log is someone else’s plugin for exactly that reason.

  • Adopt vs build: why we deleted our working logbook for SignalK

    Our boat agents log moments by voice: “log this moment” → an entry with position, time, and conditions. The first version of logbook-mcp backed that with SQLite on the agent machine. It worked. It had tests. It shipped.

  • Push SignalK alarms to your phone with a zero-dependency relay

    SignalK knows when something is wrong. It raises alarms into its notifications.* tree with a clear severity ladder:

  • SEO and GEO on TanStack Start: prerender to static

    We moved the Sailing Naturali apex site off Squarespace and onto a code-owned TanStack Start app on Vercel. The point wasn’t to save the $28/month — it was to stop editing the site through a GUI and start editing it through a git repo: content as files, every change a PR, every PR a Vercel preview, merge to ship. The repo is public: github.com/sailingnaturali/web.

  • Fix LLM formatting in the tool layer, not the prompt

    If you point an LLM agent at an MCP server and then route its replies through text-to-speech, you will eventually hear it say something like:

  • launchd's minimal PATH breaks MCP servers: uv command not found

    A stdio MCP server is just a subprocess. Your agent runtime runs something like uv run my-mcp-server, talks to it over stdin/stdout, and exposes its tools to the model. That works perfectly when you launch the agent from your terminal. Move the same agent under a launchd service — a LaunchAgent, a daily job, a bridge that restarts on crash — and the MCP tools vanish. No crash, no stack trace, no log line. The model just says it doesn’t have the tools. This is the broke → tried → fixed of why.

  • No audio from the Home Assistant Nabu puck: use assist_satellite.announce

    You wire up a local voice pipeline on a Home Assistant Voice (Nabu) puck, fire a tts.speak at it from an automation, and nothing comes out. The pipeline runs. Home Assistant reports the TTS call succeeded. There is no speech. Worse, when you poke at it in Developer Tools, the media_player entity flips to playing exactly like you’d expect — so it looks like it’s working, and you spend an hour staring at a service call that does nothing audible.

  • Route all Home Assistant voice to a custom agent with a wildcard sentence

    If you want every voice command to go to your own conversation agent — a local LLM, an MCP-backed assistant, whatever — instead of Home Assistant’s built-in intents, the obvious approach is a catch-all wildcard. On HA 2026.5+ that obvious approach returns an HTTP 500 with MissingListError, and the queries that do parse get silently hijacked by built-in intents. This is the broke → tried → fixed of getting it working.

subscribe via RSS