Skip to main content

robot-md v1.4.0: plug a servo bus and Claude announces it

8 min read By Craig Merry
robot-md opencastor RCAN MCP claude-code spatial-eval hot-plug release

This post is about what landed on robot-md main today — a release I’m calling v1.4.0. Three independent feature tracks merged across the last two days, and they’re better understood together than separately, so this is a single post rather than three.

If you’ve never seen robot-md before, the short version is in the ecosystem overview. I’ll assume you know the shape: one Markdown file at the root of a robot’s project (ROBOT.md), a Python CLI + MCP server that read it, a registry (RRF) that mints identities and verifies signatures.

Track 1 — SP-HP: the hot-plug daemon

You plug a USB cable into a Linux box. udev generates an event. Until v1.3, the operator’s job was to notice that event, identify the device, decide what kind of robot piece it is, and edit ROBOT.md by hand to declare the new driver block. That’s a lot of friction for what is, in practice, “I added another servo bus to my robot.”

v1.4 ships a small per-user daemon — robot-md hotplug-daemon — that watches the OS-native device-event stream (pyudev on Linux, ioreg + pyserial on macOS, polling on Windows) and produces a hash-chained, append-only event log at ~/.robot-md/hotplug-events.jsonl. Each event is classified into HIGH / MEDIUM / LOW based on whether the VID:PID matches a single known preset and whether exactly one matching backend is installed. HIGH-tier events get auto-bound: the daemon performs an fcntl-locked merge of a new drivers[] block into your ROBOT.md. MEDIUM and LOW events queue for operator confirmation.

Worth flagging up front: devices that share a generic USB-serial chipset across many products (CH340 — VID:PID 1a86:7523 — is the canonical example, and bob’s SO-ARM101 uses one) currently match multiple presets in the curated table, so they land MEDIUM, not HIGH. The auto-bind path is for devices with unique-enough fingerprints; everything else flows through the confirm path. Per-arm discrimination (serial-range, chip-revision) for the CH340 case is on the follow-up list.

Twenty-four tasks, all behind tests. The shape that matters most is the queue contract: pending records and resolution records are both appended; the first writer to a given event wins; subsequent resolutions raise AlreadyResolvedError. That contract is the thing surfaces (chat, terminal, future pendant, future web UI) co-operate over without coordinating with each other.

There’s also a service-installer subcommand for systemd (Linux) and launchd (macOS). The daemon stays foreground; the OS supervises.

Track 2 — SP-AN: announce + confirm

The hot-plug queue exists. Now what does Claude do with it?

v1.4 adds an MCP resource at robot-md://hotplug/pending that emits the standard notifications/resources/updated notification to subscribed clients whenever the daemon broadcasts a queue change. On Linux that’s a 1-byte AF_UNIX nudge from the daemon. On macOS / Windows it’s a 2-second mtime poll over the queue file, started inside the FastMCP server’s lifespan. Either path triggers the same notification.

The skill text in using-robot-md.SKILL.md got three new sections describing what to do when that notification fires. Paraphrased:

When you receive notifications/resources/updated for robot-md://hotplug/pending, call hotplug_review. For a HIGH-tier event that already auto-bound, announce it to the operator out loud — “Found a SO-ARM101 on /dev/ttyACM0. I bound it as arm_servos using lerobot. Say ‘undo’ to reject.” If the operator says undo within 30 seconds, call hotplug_confirm({event_id}, "reject"). The audit trail captures the operator’s intent even though manifest unbinding is a v2 follow-up.

Voice-mode operators get the announce by voice first, then mirrored to chat. Text-mode operators get only the chat. If the same event was confirmed via a separate channel — the terminal CLI, or a future pendant — Claude gets already_resolved back from hotplug_confirm and acknowledges instead of looping.

The plumbing under that skill text was the surprise of the week. The plan I’d written in advance assumed a few FastMCP APIs that don’t actually exist (@server.on_connect, server.send_resource_updated, server.state). What works in the shipped library is Context.session.send_resource_updated(uri) reachable from inside @server.resource handlers via server.get_context(). The single-session capture is opportunistic — the resource handler stamps the active session into a closure on each read; the lifespan-managed background subscriber emits to whatever’s currently captured. v1 is single-session-per-process; the multi-session generalization needs the lowlevel mcp.server.lowlevel.Server API, which exposes proper subscribe_resource / unsubscribe_resource decorators, and that’s a v2 deliverable. The trade-off is documented in cli/docs/hotplug-roadmap.md and in a public spike memo at docs/superpowers/specs/2026-04-30-span-fastmcp-subscribe-spike.md.

The end-to-end demo bob is going to run when the hardware smoke clears: replug the SO-ARM101, hear Claude announce the bind by voice within ~5 seconds, say “looks good,” continue the conversation. No manifest editing, no robot-md register --driver-id arm_servos, no remembering whether the port was /dev/ttyACM0 this time or /dev/ttyACM1.

Track 3 — SP6 Phase 1: self-attested spatial scores

SP6 Phase 0 shipped four days ago — a two-track spatial-intelligence eval (probe + execute) running inside the existing MCP server. Score JSONs were produced. They had a field for rcan_signature. The verifier was a stub that returned production verifier not wired.

v1.4 wires it. The verifier in spatial_eval_verify now loads the keystore keypair at ~/.robot-md/keys/<rrn>.signing.json for the score’s RRN and checks the signature via rcan.crypto.verify_ml_dsa. The signer in spatial_eval_run_execute (and spatial_eval_run_full) takes the same keystore lookup and stamps a base64 ML-DSA signature onto the produced Score.json before writing it to disk. Robots that don’t have a keypair on disk produce unsigned scores; verify returns the standard “no rcan_signature on Score JSON” error rather than crashing.

Two things worth calling out about the wiring.

The first is a payload-construction bug that the original Phase 0 stub embedded but the test fakes didn’t catch. The canonical bytes used for verification originally included the rcan_signature field itself, which made any real verifier impossible: you can’t sign over your own signature. v1.4’s spatial_eval/sign.py::payload_bytes clears the field before serializing, and verify uses the same canonical bytes, so the loop closes cleanly. Test injection (_verify_signature=lambda payload, sig: True) had been masking it.

The second is that spatial_eval_run_full chains probe + execute and merges the tracks in memory after run_execute_tool has already signed an execute-only Score.json on disk. That merge invalidated the signature. Without the fix you’d get invalid signature from verify instead of the clean no rcan_signature you’d want — looks like tampering, isn’t. The run_full_tool path now re-signs the merged dict and re-writes Score.json so what’s returned and what’s persisted are byte-identical and verifiable. A code-review pass before merge caught it; the fix is in commit 309559d.

This is half the SP6 Phase 1 story. The other half — RRF §27 endpoints, RRF independently re-running the held-out probe split, RRF counter-signing the Score JSON for leaderboard eligibility — is RRF-side work that landed later the same day (Phase 1.5). Phase 0 docs explicitly call it out: self-attested is dev / private / CI-gate; registry-attested is the one that ends up on the public leaderboard.

Update (later 2026-04-30): Phase 1.5 shipped — RRF #73 (the §27 endpoint) and #74 (counter-sign logic) merged, Cloudflare deploy succeeded, and bob completed the first registry-attested submission (sub_8c686d6e-6e87-49ef-8bb5-0b51e5982de7). Two follow-ups (#75, #76) cleaned up a secret-encoding mishap; keypair rotated, chain clean. See the shipped-this-week post for the full Phase 1.5 narrative.

What’s actually different

Three threads converge here. The hot-plug daemon turns “register a new piece of hardware” from a manual editing job into an OS-level event. SP-AN turns those events into a conversation in the agent the operator is already talking to. SP6 Phase 1 turns the eval scores you produce while testing all of this into cryptographically-verifiable artifacts you can hand to anyone — a CI gate, a teammate, a future Phase 2 RRF submission.

What I think is interesting is that none of these tracks needed a new long-running process. SP-HP added a daemon, but the daemon’s job is to narrow state — it’s the only writer to the queue file. SP-AN didn’t add anything; it’s a resource + skill text + a subscriber that lives inside the existing MCP-server lifespan. SP6 Phase 1 didn’t add anything either; the keystore was already there from robot-md register, the signing primitives were already in rcan-py, and the wire format is unchanged. The release moves capability without moving the moving parts.

163 unit tests across the three tracks, all green on Linux. Manual smoke on bob lands next; the bob-side checklist is at cli/tests/manual/span_smoke.md for SP-AN and at cli/tests/spatial_eval/manual_smoke_bob.md for SP6.

Up next

  • Bob smoke for SP-AN. Replug → audible announce → operator says undo / looks-good → manifest reflects it. Voice-mode parity is the bar.
  • SP6 RRF counter-sign. Wire spatial_eval_submit_to_rrf to RRF’s §27 endpoints once they land; lift the leaderboard from “self-attested” to “registry-attested.”
  • SP-AN multi-session. Drop down to lowlevel Server for proper per-session subscribe/unsubscribe handlers; remove the v1 single-session-per-process limitation.
  • Pendant. The hardware exists, the daemon-on-Pi exists; the pendant gets a “NEW HARDWARE” panel that surfaces the same queue without needing a Claude session open.

If you want to follow along, robot-md is at github.com/RobotRegistryFoundation/robot-md and the spec at robotmd.dev.