robot-md-dispatcher: when 'expose MCP remotely' is the wrong answer
If you’ve got a robot with a ROBOT.md and someone asks how to dispatch tasks to it from Slack — or cron, or a phone, or another agent — the obvious move is to expose your MCP server over HTTP. That works, but it sends every camera frame and every reasoning step across the wire, and it gives a remote agent direct access to the control surface. There’s a better shape.
Today I shipped robot-md-dispatcher — a BYOK Claude Agent SDK dispatcher that runs on the robot host, consumes robot-md-mcp unchanged, and only lets goals and results cross the network.
What this means, in plain English
Most “give my AI remote access to my robot” tutorials put a server on the robot and let a cloud-hosted AI poke at it over the internet. Every camera frame, every joint read, every reasoning step crosses the wire. That’s fine for a toy, but it’s a privacy and bandwidth problem, and a remote agent ends up one firewall-rule mistake from full control of your hardware.
robot-md-dispatcher flips that around. The AI runs on your robot. It reads the ROBOT.md file on the robot’s disk. It talks to its sensors locally. The internet only sees what you sent in (“please validate the manifest”) and what it sent back (“done, here’s what it found”). Nothing else leaves the building.
Who this is for
- If you’re running a robot in a garage or a classroom and you want to text it a task from your phone, wire it into a Home Assistant automation, or let a teammate kick off a job over Slack: this is the piece that does it without exposing the robot’s control surface to the internet. You install it, generate two tokens (one for read-only “look but don’t touch,” one for “actuate”), and you’re done.
- If you’re a roboticist or platform engineer: the dispatcher is a drop-in Claude Agent SDK host with a tier-gated HTTP front, audit logging, BYOK billing, and a hardened systemd install script. It consumes
robot-md-mcpover stdio unchanged — you don’t fork anything, you don’t wrap anything, you just install it alongside.
Three shapes for letting external systems drive a robot
I drafted all three before picking:
(a) --http transport on robot-md-mcp | (b) Python shim around robot-md-mcp | (c) Agent SDK dispatcher | |
|---|---|---|---|
| Where reasoning runs | Remote Claude surface | Remote Claude surface | Robot host |
| Unit that crosses the network | Every tool call + every result | Every tool call + every result | Goal + final result |
| Upstream change required | Yes — modify robot-md-mcp | None (shim) | None (consumer) |
| Sensor data leaves the LAN | Yes, every turn | Yes, every turn | No |
| Fits “fix upstream first” | Yes | No | Yes |
Shape (a) is the technically obvious move. Add a Streamable-HTTP transport to robot-md-mcp, put Caddy in front, ship. Reasonable to build, just not for this problem — every camera frame and reasoning step still crosses the network.
Shape (b) is the anti-pattern. If you write a Python wrapper that subprocesses robot-md-mcp and re-exposes its tools over HTTP, you’re drifting from upstream every time the tool surface changes. In this ecosystem we have an explicit principle: fix upstream first, don’t shim. A Python proxy-wrapper is a shim.
Shape (c) is the one I hadn’t tried before and turns out to be the right answer.
What Agent SDK gets you that remote MCP doesn’t
When you expose MCP tools over a network, the agent — Claude — lives somewhere else. Every tool call is a round trip: “get joint angles,” “read the camera,” “move the wrist.” The agent’s entire reasoning loop, every intermediate state, every prompt token, traverses your ingress. On a robot streaming OAK-D frames back as tool results, that’s measurable bandwidth per step. More importantly, the remote agent has direct access to the raw control surface, sequenced however it likes.
With the Claude Agent SDK, you flip the topology. The agent runs on the robot host. It connects to robot-md-mcp via local stdio. It reads ROBOT.md from disk. It calls tools, gets results, keeps reasoning — all locally. What crosses the network is exactly what should: a goal in, an NDJSON stream of progress and a final result out.
External system (Slack bot, cron, phone, another agent)
│ HTTPS POST /dispatch {"goal": "..."}
▼
robot-md-dispatcher ────▶ Claude Agent SDK session
(tier gate, │ local stdio
audit log, ▼
BYOK env) robot-md-mcp ──▶ robot
Everything right of the dispatcher stays local. The network carries one request and one response stream. Nothing else.
Show me the request
curl -N https://robot.example.tld/dispatch \
-H "Authorization: Bearer <tier-token>" \
-H "X-Anthropic-Api-Key: sk-ant-..." \
-H "Content-Type: application/json" \
-d '{"goal": "validate the manifest and describe what the robot can do"}'
Two headers matter. Authorization carries a bearer that maps to a caller and a tier — read or actuate. X-Anthropic-Api-Key is the caller’s own Anthropic key; we’ll get to that.
The response is NDJSON — one JSON object per agent message. You watch the agent plan, call tools against robot-md-mcp, iterate on intermediate results, and land a final answer. It’s like a Claude Code session, except the session is happening on a Pi you’ve never logged into.
Bring Your Own Key
I wrestled with billing. The robot host cannot be in the billing path. The moment you put shared inference dollars behind a control plane you just built, you’ve turned every operator into a micro-SaaS — metered inference, per-caller quotas, Stripe webhooks, failed-payment retries. Nobody running a robot in their garage signs up for that.
So the dispatcher doesn’t touch billing. Every caller supplies their own sk-ant-... via the X-Anthropic-Api-Key header. The dispatcher passes it to the per-request Claude Agent SDK subprocess via env={"ANTHROPIC_API_KEY": <caller_key>}, and Anthropic bills the caller’s org directly.
Three properties fall out of this:
- The operator hosts the control plane; the callers pay for inference. If you run a dispatcher in your garage, every friend who dispatches a task pays for their own Claude usage. You pay for the Pi and the electricity.
- Keys never leak to logs. The audit log uses a
sha256[:12]fingerprint as the correlation ID. The real key only ever exists in theAuthContextdataclass in memory and in the per-request subprocess env. It’s not persisted, not forwarded, not printed. - A shared-key/metered layer is a downstream concern. If you want to run a turnkey service with a single Anthropic account and per-user quotas, build that on top — it’s an opencastor-ops-shaped concern, not a dispatcher concern. Separation of policy from mechanism.
The tier gate — and the bug I almost shipped
The dispatcher has two bearer tiers: read (observation only) and actuate (everything). They’re auth’d the same way; the difference is what the gate lets through.
My first cut of the gate was a list of prefixes that “obviously” indicated actuation: move_, set_, emergency_, home_, grip_. Then, just before declaring done, I went and looked at what robot-md-mcp actually exposes:
render, validate, estop, estop_clear,
execute_capability, execute_task,
vision_find, record_skill, discover
None of my prefixes match any of those. With the policy I had drafted, a read-tier caller could call estop on a running robot. Not what anyone wants.
The fix was to invert the policy. Instead of default-allow with an actuation denylist, the dispatcher uses default-deny with a read-safe allowlist. A read-tier caller can invoke:
render, validate, vision_find, discover,
get_*, list_*, describe_*, status
Everything else — including any new tool added to robot-md-mcp next week — requires actuate tier. Operators who want to loosen the default make an explicit choice; no one inherits a silent relaxation.
Then I pinned the real robot-md-mcp tool names into the test suite:
REAL_ROBOT_MD_TOOLS_READ = (
"mcp__robot__render",
"mcp__robot__validate",
"mcp__robot__vision_find",
"mcp__robot__discover",
)
REAL_ROBOT_MD_TOOLS_ACTUATE = (
"mcp__robot__estop",
"mcp__robot__estop_clear",
"mcp__robot__execute_capability",
"mcp__robot__execute_task",
"mcp__robot__record_skill",
)
If upstream renames validate to validate_and_run next quarter, the test fails loudly instead of silently relaxing the gate.
Shipping plan includes a drift check
Two weeks from today, a scheduled agent in Anthropic’s cloud will do five things:
- Create a fresh Python venv.
pip install claude-agent-sdk(latest).- Exercise every
ClaudeAgentOptionsfield the dispatcher relies on —max_turns,max_budget_usd,permission_mode,mcp_servers,can_use_tool,hooks,env,system_prompt. - Clone the public repo and run
pytest -q. - Report under 200 words: what drifted, what didn’t, whether any action is needed.
This isn’t CI. It’s a one-shot check fired well after the release window, designed to catch the specific failure mode where the SDK API shape changes and a dispatch silently breaks. If it comes back clean, great. If it comes back with max_budget_usd renamed to max_spend_usd, I get a targeted early warning.
Spending fifteen seconds creating a cron routine during a feature’s release, then getting a short report when it matters — this is one of the genuine advantages of having agents in your infrastructure.
How to use it — three paths, pick the one that fits
robot-md-dispatcher 0.2.0 (shipped today) scaffolds all its secrets and config with an init command, so you never hand-edit YAML to get started. There are three ways to run it, corresponding to three levels of “how much hand-holding do you want”:
1. Guided — for first-time setup. Explains every knob as it asks.
pip install robot-md-dispatcher robot-md
robot-md-dispatcher init # interactive; walks you through it
robot-md-dispatcher serve --bearers ./bearers.yaml --robot-md ./ROBOT.md
2. One-shot — for repeat setups and automation. All defaults, no questions, prints a generated actuate-tier token exactly once (save it — it’s not stored anywhere else).
robot-md-dispatcher init --yes
robot-md-dispatcher serve --bearers ./bearers.yaml --robot-md ./ROBOT.md
3. From inside Claude Code — for operators who already have the plugin installed. Run the slash command /enable-dispatch from a session where the robot-md-mcp plugin is active. It runs init --yes for you on the robot host but does not print the generated token into the conversation — Claude provisions everything without ever seeing the secret. This is the path to recommend when an operator wants the dispatcher set up by the same session that’s already talking to their robot.
/enable-dispatch
init also hard-fails if its preconditions aren’t met: ROBOT.md must exist and validate against the canonical schema, and robot-md-mcp must be on PATH. No silent half-configs.
For production, systemd/install.sh in the repo sets up a dedicated robot system user, a hardened systemd unit with resource ceilings (MemoryMax=1G, CPUQuota=80%), and proper DeviceAllow entries for the serial bus. Ingress is Tailscale Funnel — the dispatcher binds to 127.0.0.1 by design and there’s no good reason to port-forward it.
Three paths, same pattern
The three paths above aren’t a coincidence — they’re the same pattern the robot-md CLI uses. Working defaults first (--yes gets you running), precision opt-in (guided mode explains every knob), and a zero-friction path from inside a Claude Code session (/enable-dispatch) for operators who shouldn’t be editing YAML at all. Underneath, the policy stays locked down: default-deny allowlist on the read tier, tokens generated from secrets.token_urlsafe(32), keys never written to logs, audit hooks on every tool call.
Where this fits
ROBOT.mdis the declaration: one file at the robot’s root.robot-md-mcpis the agent bridge for interactive MCP clients — Claude Code, Claude Desktop, Cursor, Zed, plus Gemini CLI and OpenAI Codex CLI (both verified today). A human drives.robot-md-dispatcher(new) is the remote dispatch layer — a program hands the robot a goal from outside, the Claude Agent SDK runs the loop on-host, the caller pays for inference.
Repo: github.com/RobotRegistryFoundation/robot-md-dispatcher. Apache-2.0. 15 regression tests green, default-deny policy pinned, systemd unit and Tailscale setup included.
If you’ve been using robot-md and robot-md-mcp for interactive work and wanted the same capabilities from a bot, a cron job, a phone, or another agent — without exposing the robot’s MCP server to the internet — this is the piece.