-
-
The model proposes, the gate decides. A pure function: no model, no randomness, no network.
-
The gate refuses a trip into a no-entry zone on the real map. The robot never moved.
-
Refused: Nav2 can plan a route to the treadmill, but it lies inside a forbidden zone. One rule fires, one second after the request.
-
Allowed the gate clears the trip and the server issues a single-use token valid for 30 seconds. Nothing moves until a person clicks Confirm.
Robot Operator
Voice control for a home robot, with a deterministic safety gate between the language model and the wheels.
Inspiration
An agent that can drive a machine around a home is a different proposition from an agent that can write an email. A bad email wastes a minute. A robot that drives into a staircase, or into a person, does not.
The usual answer is to make the model more careful: a stern system prompt, warnings in the tool descriptions, a fine-tune. That improves the average case and does nothing for the one that matters, because it is still the model deciding — and a model's decision is a sample from a distribution. You can shift the distribution. You cannot put a floor under it.
So I put the floor somewhere else.
What it does
You talk to a real robot — a Yahboom ROSMASTER X3 running ROS 2 and Nav2 in my flat — in plain English. A model understands what you said and chooses which tools to call. But the model never decides whether the robot may move. That answer comes from a few hundred lines of ordinary Python with no model in them, and nothing moves until a person says yes.
Between the tools and the hardware sits a gate: a pure function from the robot's measured state and a proposed move to one of three verdicts.
| ALLOW | go |
| LIMIT | go at reduced speed, and say why |
| STOP | do not go, and say why |
The gate has no model in it, no randomness, and no network calls. The same input gives the same verdict every time — there is a test that asserts exactly that. It can be read in one sitting, argued with, and unit-tested, which is not true of a prompt.
The model's job is narrowed to what models are good at: understanding a sentence, choosing a tool, and explaining a refusal in words a person understands. It is not trusted with the decision, so it does not have to be trustworthy for the system to be safe.
Nothing moves without a person
Every move is two calls, not one.
propose_move plans a route, runs the gate, and — if the verdict is not
STOP — issues a single-use token that expires in 30 seconds. It changes
nothing.
confirm_move takes that token and is the only thing in the system that
makes the robot move. It redeems the token with a pop, so replaying it fails.
And it runs the gate again before driving: the world may have changed in the
seconds between the question and the answer, and a verdict earned a moment ago
is not a permission that keeps.
stop is the exception. It is never gated, never confirmed, and it
discards every outstanding token.
How it works
speech ──▶ browser host ──▶ Amazon Bedrock (Claude Haiku 4.5)
│ │
│ tool calls
▼ ▼
panel / map ◀────── MCP client
│ Streamable HTTP
▼
MCP server · 8 tools
│
┌──────┴──────┐
▼ ▼
gate adapter
(deterministic) ┌────┴────┐
▼ ▼
FakeRobot RosRobot
│ rosbridge
▼
ROS 2 · Nav2 · X3
A self-hosted MCP server (spec 2025-11-25, Streamable HTTP, stateless JSON)
exposes the robot as eight tools: get_status, list_places, propose_move,
confirm_move, stop, explain_stop, recent_events and get_scene.
The gate never sees the adapter and the adapter never sees the gate. They
meet only through two frozen dataclasses, RobotState and MoveProposal.
That is why the same gate runs unchanged against a robot in memory and against
the real one — and why its seventeen tests need no ROS installed.
The host is an operator panel in the browser: speech in, a live map, the conversation, and a safety log of every decision the gate has made with the reason it gave.
The rules
Each rule is a function with the same signature; they are collected in one
list, so adding a rule is adding a function. Level is an IntEnum, so
combining verdicts is max() — the strictest wins, and every reason that fired
is reported, not just the first.
| Rule | STOP when | LIMIT when |
|---|---|---|
check_state_age |
telemetry is older than 2 s | — |
check_localization |
AMCL's covariance says it does not know where it is | — |
check_battery |
mean voltage below 10.8 V | below 11.4 V |
check_plan |
the planner returned no route | — |
check_zones |
the goal is inside a forbidden zone | — |
check_clearance |
narrowest point < 1.1 × robot width | < 1.5 × robot width |
check_obstacle |
something within 0.30 m right now | — |
Three details that came from the hardware rather than from theory:
Voltage is a rolling mean, not a reading. A motor starting up drops the pack by half a volt for a moment. Gating on the instantaneous value would make the robot refuse to move at exactly the moment it starts moving.
A flat battery does not strand the robot. Below 10.8 V every trip is refused except one: a move whose goal is the charger is allowed, at reduced speed, with the reason said out loud. The alternative is a gate that is correct and useless — a robot that has decided it is too low on power to move, and therefore cannot reach the thing that would fix that.
Verdicts have hysteresis. Without it, a robot sitting at 10.79 V flips between STOP and LIMIT with every reading and the light bar strobes. The threshold to recover is 0.2 V higher than the threshold to trip.
The forbidden zone, and why it is the interesting part
A zone is a polygon on the map with a name. The one in this flat is the corner of the living room with a treadmill and the desk somebody in my family works at.
Two independent reasons to keep the robot out, and only one of them is visible to the robot: the treadmill leaves no clear way through, and a person sits there. The map cannot see the person.
That is the whole argument in one object. It is exactly the kind of thing an operator knows and a model cannot derive from sensors, it is specific to this machine in this building, and the refusal it produces is a technical fact rather than an apology. The goal inside it is a real, reachable place — Nav2 will happily plan a route there — so when the robot refuses, it is refusing on its own authority, not because the planner failed.
The light bar
The X3 has an addressable LED strip across its back. The panel shows the verdict to whoever is holding the laptop; the light bar shows it to everyone else in the room, which is the case that actually matters — an operator with both hands busy is not looking at a screen.
Dim blue is listening. Green breathing means a move is proposed and waiting on a human. Green is cleared and moving, orange is moving under a restriction, red is refused. Only the waiting state animates, and it breathes rather than blinks, because a blinking machine reads as a fault and this is an ordinary pause.
Driving it needed a fifteen-line addition to Yahboom's own driver node, which does not expose the LEDs on a topic. The patch is in the repository.
Why there is a hand-built host
The Alexa+ developer toolkit is not available to hackathon participants. The
organisers confirmed this in the Devpost discussions and suggested a self-built
front end instead, so the host here is a real MCP client written from scratch —
about seventy lines: initialize, tools/list, tools/call, with the
protocol version enforced. It speaks to the server exactly as any other client
would.
This turned out to be worth more than the toolkit would have been. Writing the client by hand is what surfaced most of the entries in the friction log.
Built with
Amazon Bedrock — the Converse API running Claude Haiku 4.5
(us.anthropic.claude-haiku-4-5-20251001-v1:0), at temperature 0.0. The agent
loop lives in host/agent.py: it hands Bedrock the tool definitions it got from
tools/list, turns each toolUse block into an MCP tools/call, and feeds the
result back as a toolResult. The Converse tool-use shape maps onto MCP almost
one to one, so there is no translation layer. Access is through a dedicated IAM
user with bedrock:InvokeModel, a named CLI profile and a zero-spend budget.
Model Context Protocol 2025-11-25 over Streamable HTTP, MCP Python SDK v2.
ROS 2 Foxy, Nav2, AMCL, gmapping on a Raspberry Pi 5 inside the robot,
reached over rosbridge with roslibpy. Positions come from /amcl_pose in
the map frame; LIMIT verdicts are applied by publishing nav2_msgs/SpeedLimit.
FastAPI, WebSockets and the Web Speech API for the operator panel.
What was built during the submission window
The robot is a kit I already owned and had driven before. Everything in this repository is new: the gate and its tests, the MCP server and its tools, the token scheme, the adapters, the MCP client, the Bedrock agent, the operator panel, the light bar and the driver patch. The map of the flat was made during the window too.
What this does not do
Worth saying plainly, since the point of the project is not overclaiming.
- The gate is as good as its inputs. It reasons about clearance from the closest laser return rather than by sampling the costmap. That errs towards refusing, which is the right direction, but it is a coarse number.
- Localisation is detected, not solved. The gate knows when AMCL has lost the robot and refuses to move it. Getting it un-lost is Nav2's problem.
- The tools are the only route to the hardware. A model that never calls
propose_movecannot be stopped by this design — which is whyconfirm_moveis the single door, and why it re-checks. - The map is the weak link. The zone and the coordinates are only as trustworthy as the SLAM run behind them.
Where it could go
The principle is not about one robot in one flat: a safety layer the operator cannot talk around, that logs its reasons, and that can be asked afterwards why it refused. That shape fits anything where an agent touches the physical world and a person is accountable for the result — warehouse vehicles, lab automation, agricultural machinery. The gate here is forty lines of rules and seventeen tests. The rules would be different elsewhere. The arrangement would not.
Disclosure
Built solo. I used Claude as a pair programmer throughout — design discussion,
code review and drafting — and the commit history records it with
Co-Authored-By lines. Every design decision, every measurement and every
error in this project is mine to defend.
Repository: https://github.com/milasterb/robot-operator Demo video: TODO Track: Alexa+ — self-hosted MCP server Mini challenge: AWS Builder
Built With
- amazon-bedrock
- amazon-web-services
- amcl
- claude
- fastapi
- lidar
- mcp
- nav2
- python
- raspberry-pi
- ros2
- rosbridge
- roslibpy
- slam
- web-speech-api
- websockets
Log in or sign up for Devpost to join the conversation.