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

  • 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.

  • 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.

  • 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