Integrating a robot — the L4/L5 MCP contract¶
The brain runs centrally on a shared host (PhyAI4090, the 4090) and is an LLM
orchestrator with no robot knowledge of its own. You run your robot — real
or simulated — on your own machine, expose it to the brain as one or more MCP
servers over Tailscale, and drive it from a web page. You get your own named
instance, isolated from everyone else.
This is the single doc for connecting a robot, and it is the contract itself: the canonical tool set (§2), the conventions every implementation honours (§2d), what the skill layer expects from your primitives (§2e), the capabilities that unlock the rest of the brain (§3), and one checklist (§4). Everything above the contract — the brain, the plan runtime, the reusable L3 skill services — is written once; you implement the layer below it. Two implementations of the same contract (your sim and your real robot) make sim→real transfer free.
Audience
Robot builders — real robot or sim — connecting a robot to the central brain as MCP servers over Tailscale. It assumes you can write robot code and wrap it as MCP; you need no brain internals. To use and operate the brain once connected, see Using the brain; to run the brain that drives it, Running a brain.
Canonical reference implementations to copy from:
- the whole L4/L5 contract in one adapter →
r1-abstraction(:9220) — the R1's implementation (it also serves itsprompt://system). §2f records exactly what it exposes and where it deviates. - an L3 skill →
grasp-service(:9210,grasp(object)); the copy-me scaffold isskill-service-template. - the scene/vision service →
binabik-r1-vision(:9230) —capture_scene/get_frame/pixel_to_3d, and the WebRTC stream sender behindstream_start/stream_stop(STREAM_ENABLE, on by default wherever the vision service runs). - processes/skills memory →
robot-mcp-memory.
Don't build on the pre-abstraction stack
The old two-server split — motion :9206 + locate :9207 from rap-integration —
is gone: the repo was archived (BIN-95) and brainctl's fallback to that pair
removed (BIN-108). grasp-service's CAP_URL / LOCATE_URL pointed there until
BIN-135; both now default to the adapter (:9220). robot-mcp-perception (v2
perception) is archived too, superseded by binabik-r1-vision + galaxea's on-demand
perception. Build against the contract below, not against either of them.
Prerequisites¶
On the robot's machine (or wherever your MCP servers run):
- Tailscale, joined to the tailnet (
tailscale up) — the brain reaches your servers only over the tailnet. - Python ≥ 3.11 and uv — the Binabik Python
services are managed with
uv(uv sync,uv run …). - git SSH access to
github.com/binabik-ai— the shared MCP kit,robot-mcp-kit, is a private git dependency (ToolClient, server helpers,ParamStore,RecoveryAgent), resolved over SSH rather than PyPI. You need an authorized SSH key touv sync. In CI, consumer repos authenticate with a read-only deploy key (repo secretRMK_DEPLOY_KEY). - Enough CPU/GPU for your robot/sim — that's the heavy part; the brain is light and lives on the shared host.
You do not need Docker or any brain source to connect — only the admin (running
brainctl on the shared host) does.
Quick connect to the shared brain¶
your machine shared host
┌────────────────────────┐ Tailscale ┌──────────────────┐
│ your robot / sim │◀────────────▶│ brain-<you> │──▶ http://<host>:<port>
│ + MCP servers (SSE) │ │ (Claude planner) │
│ r1-abstraction :9220 │ └──────────────────┘
│ grasp :9210 │
└────────────────────────┘
The brain is just an orchestrator (no ROS, no GPU). It plans and then calls tools on your robot; your machine does the actual sensing and acting.
What you run: one or more MCP servers reachable on the tailnet, bound to 0.0.0.0
(so the remote brain can connect), implementing the canonical tools of §2 — L4 motion/IO
and L5 perception, plus any L3 skill services you want. How those groups are split
across servers, and on which ports, is your choice: the brain attaches exactly the
URLs listed in that instance's attach manifest and cares only about the tool names.
Any robot that speaks this contract works — Gazebo, Isaac, or hardware.
Reference implementation (Galaxea R1 Pro)
The canonical implementation is r1-abstraction (:9220) — one adapter
presenting both motion and perception (the full L4/L5 contract) over
galaxea_agent's own servers, plus grasp-service (:9210) for the L3 grasp
skill and binabik-r1-vision (:9230) for scene capture. The R1's whole
robot-side stack comes up with one command, r1ctl up — see
the R1 robot stack — and it is what the default attach manifest
(robots/r1.yaml) expects. Bringing a different robot means running your own MCP
server(s) with the same tool names and giving that instance its own
robots/<name>.yaml listing them.
Steps:
1. Join the tailnet (once):
2. Start your robot + its MCP servers, then sanity-check they listen:
3. Ask the admin for an instance — give them your tailnet hostname. They start one brain container for your robot (Running a brain) and reply with your URL. Running the brain yourself instead? See Set up your own robot + sim.
4. Use it — open the URL, type a command, open the Debug panel to watch the brain reason and call your robot's tools. Improvements the admin makes to the brain reach everyone after an update. Everything stays private on the tailnet.
Nothing is exposed publicly; lock the tailnet down with Tailscale ACLs.
0. The shape of it¶
Your tools are the whole interface. They must be:
- coarse, blocking, self-verifying — one call = one whole primitive (
pick,navigate_to_named) that returns only when finished and reports success/failure; - stateless — explicit inputs → data out, no hidden per-object state a later call
depends on (§2e; the MoveIt planning scene is the one legitimate exception, and it is
mutated only through explicit
attach/detach/add/clearcalls); - locally safe — keep your own e-stop/reflexes; a dropped connection mid-call must fail safe on the robot (the brain auto-reconnects);
- clearly described — the planner picks tools from their names + docstrings;
- structured — return JSON (
{"ok": true, …}); the plan machine branches on it; - bare-named — expose them with no server prefix (the brain calls
pick, notmotion.pick); dotted names intermittently break tool routing.
response_text is auto-injected into every tool's schema by the brain (spoken
text alongside a call) — ignore it in your handler.
0b. Where your code goes — the layer model¶
Everything you build sits in one of a few layers, each owning a different kind of knowledge. Putting a capability in the right layer is what keeps one brain image serving every robot and customer — and lets you make the robot better at a task without ever changing the brain. (The full version of this model, and where each piece runs, is architecture.md §2/§2a.)
| Layer | Owns | Per-what | You build it as |
|---|---|---|---|
| Core brain | conversation, planning, the generic plan runtime (data-flow, expect gates, loops, survey/retry), oversight, memory |
universal (one image) | — (you don't touch it) |
| Configuration | prompt fragments, plan templates, named places, model choice, which servers to attach | customer / robot / task | prompt + config (data, not code) |
| Skill / task services | a closed-loop, self-verifying competence behind ONE high-level tool | object / task | an MCP server |
| Capability primitives | atomic motion/IO — move, navigate, pick, place, get_pose |
robot / gripper | an MCP server (§2a) |
| Perception / world model | ground, segment, localize, grasp geometry, scene capture | sensor / model (swappable) | an MCP server (§2b) |
The load-bearing idea: competence lives in the lowest layer that can own it end-to-end and verify itself, and a refinement loop belongs where the feedback signal is — never in a prompt or in the brain.
- Primitives (§2) are atomic, blocking, self-verifying, and dumb:
pickattempts one grasp and reports{ok, held}. It does not re-perceive or retry. - A skill/task service wraps a hard, object-specific competence as a single
high-level MCP tool — e.g.
grasp("coke can")— and owns the closed loop internally: perceive → grasp → check the grip → refine (nudge the base, adjust standoff, try the other arm) → repeat until it verifies success or fails honestly. It calls your primitives beneath it (§2e), and may bundle its own AI models (a task-specific segmenter, a grasp net). To the brain it's just another tool, so the compiled plan shrinks to one step.
So a new object, gripper, robot, or customer is a new MCP server or a config change — never a brain fork. Add generic capability to the brain (everyone benefits); add specific competence here (isolated).
1. Publish + connect (every robot)¶
- Tailscale on the robot's machine:
tailscale up. - Run your MCP servers as SSE, bound to
0.0.0.0so the remote brain reaches them over the tailnet. WithFastMCP:Bindingmcp.settings.host = "0.0.0.0"; mcp.settings.port = 9220 # Binding non-localhost trips the MCP SDK's DNS-rebinding guard (HTTP 421 # "Invalid Host header"). Relax it for a tailnet-only server: from mcp.server.transport_security import TransportSecuritySettings mcp.settings.transport_security = TransportSecuritySettings( enable_dns_rebinding_protection=False ) mcp.run(transport="sse")127.0.0.1is the single most common mistake: the server looks healthy locally and is unreachable from the brain. - Ask the admin for an instance (send your tailnet hostname). For the converged R1 the default attach manifest already fits; a non-R1 robot needs its own manifest naming your servers. Both cases — and the exact command — are in Running a brain.
That's enough for the brain to connect. What you implement next decides how much of the brain you can actually use.
Transports and URLs — get these right first¶
The brain (and every ToolClient in the stack) picks its transport from the URL, so the
path matters:
| Server | Port | Transport | URL |
|---|---|---|---|
r1-abstraction (L4/L5 adapter) |
9220 | SSE | http://<host>:9220/sse |
grasp-service (L3 skill) |
9210 | SSE | http://<host>:9210/sse |
binabik-r1-vision (L5 scene) |
9230 | SSE | http://<host>:9230/sse |
binabik-docking (L4 marker servo) |
9250 | SSE | http://<host>:9250/sse |
galaxea r1_manipulation · perception · nav2 · ros |
8004 · 8003 · 8001 · 8005 | streamable-HTTP | http://<host>:<port>/mcp |
So: binabik's own servers speak SSE at /sse; galaxea's speak streamable-HTTP at
/mcp. Expose your servers as SSE at /sse — that is what the attach manifests
list (transport: sse).
A transport/path mismatch looks like a mystery crash
Point a client at the wrong path or transport (e.g. …:8004/sse for a
streamable-HTTP server) and the first call dies with "unhandled errors in a
TaskGroup" — no hint about the URL. Check the path before debugging anything else.
A server needn't run on the robot
The brain connects to each MCP server by URL, so a capability too heavy for the robot's onboard compute — e.g. a VLA wrapped as an MCP server, or a heavy segmenter — can run in the cloud (or any host) and be attached alongside the robot-local ones; to your robot code nothing changes. The admin adds the extra server and its auth header when launching the instance (see Running a brain). Bind cloud servers with TLS + a bearer token; the brain connects to each server independently.
A camera-consuming service off-robot still needs the frames
Attaching its tools is easy (MCP is just a URL); getting it the robot's camera
stream is the real work, because the camera lives in ROS on the robot and the
brain is not in that path. Two patterns (architecture.md §3 names the split):
- (A) keep a thin MCP server on the robot holding the camera subscription and
have it call a stateless cloud model per request — this is what
binabik-r1-visiondoes (local rclpy node, cloud grounding call) — best for request/response; or - (B) run the whole server off-robot and stream the camera to it (WebRTC / RTSP / gRPC over Tailscale) — best for a continuous, high-rate consumer such as a closed-loop VLA.
A tight VLA loop over the public internet is latency-bound; prefer a GPU box on the
robot's own LAN. Either way the control plane is unchanged: tools +
prompt://system over MCP.
2. The canonical tool set¶
This is the contract. The set is deliberately small and generic — the operations
every mobile manipulator has, plus an object-centric grasp primitive (pick: say
"grasp this object", and the robot chooses the mechanics). What is not in it: the
generic grasp skill loop above it (one L3 service, identical across robots), and your
robot's grasp mechanics below it (choreography, IK search, empirical constants).
In the tables, ● = implement this to drive the robot at all, ○ = optional (it unlocks a skill, a UI, or step-level recovery).
2a. Layer 4 — capability (motion / IO)¶
| Tool | Args | Returns | Meaning | |
|---|---|---|---|---|
| ● | navigate_to_named |
name |
{ok, name, x, y, yaw_deg}; unknown → {ok:false, known:[…]} |
Drive the base to a known named place. |
| ● | list_waypoints |
– | {ok, waypoints:{name:{x, y, yaw_deg}}} |
The places the robot knows; injected into planning. |
| ○ | navigate_to |
x, y, yaw_deg |
{ok} |
Drive to a raw map-frame pose. |
| ○ | turn_by |
degrees (CCW+) |
{ok, requested, achieved, collision_checked, error} — achieved in degrees |
Rotate the base in place (jog, survey, recovery). achieved is the measured turn — see the rule below — and the move must be collision-checked (rule). |
| ○ | drive_by / strafe_by |
distance_m |
{ok, requested, achieved, collision_checked, error} — achieved in metres |
Straight / sideways base jog (strafe_by: holonomic bases only). Same two rules; an axis with no checked equivalent must report collision_checked: false with a reason. |
| ○ | go_to_stance |
target_x, target_y, stand_x, stand_y, stand_yaw_deg, grasp_z |
{ok, reachable, reachable_side, final_dist_m} |
Drive into a working stance for an object + report arm reachability. |
| ○ | approach |
pose, face_normal (or an object handle) + stop_dist, direction |
{ok, final_dist_m} |
Stand off along the object's face normal + fine-dock — the stateless form the grasp FSM drives. stop_dist is the caller's standoff and is authoritative — see the rule. direction names which side to come at; default to your own choice when it is omitted (rule). |
| ○ | feasible_approach_directions |
object_id |
{ok, directions: [{angle_deg, …}], recommended, arc_deg} |
Where the base could stand around an object, per your costmap. Answer in the same units approach(direction=…) accepts, and keep an error distinguishable from an empty set — see the rule. |
| — | pick |
removed from the contract, 2026-08-13 (BIN-297) | — | Do not implement this. A one-shot object-centric grasp duplicates what the L3 grasp skill owns, and on the R1 the version that existed could never work: it called stateful robot tools that act on an object a previous chain step established. Picking is a skill, not a primitive — implement the primitives the skill layer expects and let it own the loop. |
| ● | place |
side + optional target ({x, y, z, frame_id}) |
{ok, released, verified} |
Set the held object down. released must be measured, not assumed — verify it (a held-check read inverted, or a gripper-load read) and report verified: false when you could not. The R1 returned a hardcoded true here for months; see the rule. |
| ○ | move_arm |
side, pose, frame_id [, position_only, with_torso] |
{ok, reachable} |
Move one arm's end-effector to a pose. That arm only — extra DOF (a torso, a waist, the base) are opt-in via with_torso, never recruited silently. See the rule below. |
| ○ | goto_named |
group, state |
{ok} |
Move a joint group to a named posture (e.g. upper_body/pick_mid_height). |
| ○ | set_gripper |
side, state (open/close) or position |
{ok} |
Open/close a gripper. |
| ○ | plan_execute |
group, joints or pose+frame_id [, octomap_guard] |
{ok} |
Plan + execute one motion (the move half of a grasp FSM step). |
| ○ | compute_ik |
group, ee_link, pose, frame_id[, …] |
{ok, joints} |
IK for a pose (diagnostic / skill use). |
| ○ | compute_grasp_joints |
pose, dims, face_normal, approach, phase |
{joints, reachable, ik:{position_reachable, fix_hint}} |
The heavy grasp math (roll-search, dual-arm squeeze IK) — owns its own search budget. Must answer for every approach your robot claims, and for each phase that approach's FSM solves — see the rule below. |
| ○ | scene: add_object_to_planning_scene, allow_arm_vs_object, allow_arm_vs_octomap, attach_object, detach_object, remove_object_from_planning_scene, clear_octomap |
see the reference server | {ok} |
Explicit planning-scene state: register/attach the object, relax the right ACM entries, drop stale octomap voxels. Skipping these is what produces MoveGroup START_STATE_IN_COLLISION. attach_object owns the whole ACM exemption for the held object, and detach_object alone does not remove the object — see the two warnings below. |
| ○ | release_box |
– | {ok} |
Physically let go and home the upper body: widen the arms, detach and remove the collision object, clear the octomap. Required if your grasp is an arm squeeze rather than finger actuation — set_gripper(open) releases nothing there, and grasp's release-and-retry has no other way to undo a failed attempt (BIN-170). |
| ○ | tilt_for_depth |
box_z |
{ok} |
Torso over-look for depth recovery. |
| ○ | get_pose |
– | {ok, frame, x, y, yaw_deg} |
Current base pose, in whichever frame your localization owns (report it, don't take one as an argument). The brain's Waypoints panel calls this for "use current position", so a robot without it can only be taught places by typed coordinates. |
| ○ | gripper_state |
side |
{side, finger_width, load, state?, holding?} |
Finger opening + load, and what they mean. state is "open" / "closed"; holding says the fingers are pressing on something. Report a verdict only where you are sure, and omit it otherwise — see the warning below, because the asymmetry is the whole design. |
| ○ | joint_state |
group |
{ok, group, joints, count, at_limit, limits_available, limits_reason} |
Where the robot's own joints are. group is one of your named planning groups — the brain asks for "the left arm", never for a list of your joint names, so publish the group names and keep the joint names inside. Each joint: {name, position}, plus velocity/effort if you have them, plus {lower, upper, pct_of_range, at_limit} when the joint is bounded and you know its limits. Omit the range keys rather than defaulting them when limits are unavailable, and say so in limits_available / limits_reason — see the warning below. An unknown group is an error naming the valid ones, never an empty list. |
| ○ | ee_pose |
side, frame |
{ok, side, frame, x, y, z, qx, qy, qz, qw} |
Where one end-effector actually is. Joint angles say what the arm is doing; this says where it ended up, which is what a skill doing geometry wants ("is the gripper already near the target?"). Take a side, not a link name — link names are your vocabulary, not the brain's. |
| ○ | fault_state |
– | {ok, fault, faults, groups, readable, unreadable} |
What your robot reports about its own faults — e-stop, motor/driver faults, self-collision. ok means the reads happened, never that the robot is healthy: the verdict is fault, and it must be absent, not false, whenever you could not read it. faults names each finding ({group, joint, code, description}); groups gives the per-group outcome so a silent actuator is diagnosable from the payload; unreadable maps every signal you cannot report to why. Read the rule below before implementing — the asymmetry in it is the whole design. |
| ○ | dock_to_marker |
[dock, tag_id, offset_m, max_retries, timeout_s] |
{ok, code, reason, attempts, elapsed_s, error:{x, y, yaw_deg, distance_mm}, stopped} |
Drive the base onto a fiducial marker — the last metre a waypoint cannot do. Blocking, and cancellation must stop the base (the rule). Name a taught dock or a raw tag_id, never both. ok: true only when the robot really reached the commanded pose; code is a stable token and reason is the sentence with the numbers in it — callers branch on code. |
| ○ | undock |
[dock, distance_m, timeout_s] |
{ok, code, reason, method, travelled_m, stopped} |
Back off to the standoff. method says how the distance was measured ("marker" / "odometry") — an answer that came from dead reckoning is not the same claim as one that watched the tag, and a base that reports itself slipping must refuse rather than quote a number (the rule). There is no obstacle check behind the robot; say so. |
| ○ | save_dock |
name[, tag_id, family, offset_m, yaw_offset_deg, tolerance_m, tolerance_deg] |
{ok, reason, dock, replaced} |
Teach a dock. Unlike a waypoint, a dock survives a restart — it is defined against a marker bolted to a machine, not in a map frame SLAM rebuilds at every bring-up (see named navigation). Angles in degrees, same as everything that reads them back. Tolerance is per dock, because accuracy is a property of the marker and the approach angle, not of your control law. |
| ○ | list_docks |
– | {ok, count, docks:[{name, tag_id, family, offset_m, yaw_offset_deg, tolerance_m, tolerance_deg}]} |
The docks the robot knows; injected into planning the way list_waypoints is. |
| ○ | dock_status |
– | {ok, running, current, last_run, marker, sink, deadman} |
What the servo is doing. The one docking tool safe to poll while an approach runs, and the only place a cancelled run's outcome can be read — a cancelled call cannot return a value to the caller that cancelled it. |
| ● | stop |
– | {ok, stopped, goals_cancelled} |
Preemptive safety stop: zero /cmd_vel, cancel live motion/nav goals, raise the cooperative flag. Must be serviced out-of-band (§2d). |
| ● | clear_stop |
– | {ok} |
Clear the stop flag at the start of a plan. |
open is the one verdict that must never be guessed (BIN-501)
The brain drops its held-object belief when every gripper reports open (BIN-396). So a
gripper wrongly called open leaves the robot planning as though its hands were free — which is
worse than the stale belief that mechanism exists to clear. The costs are not symmetric, and
your thresholds should not be either.
openmeans at the mechanical stop, not merely wide. On the R1 that is 0.107 m of a ~0.110 m hand, chosen so that anything the gripper could actually be holding stays below it.closeddoes not imply empty. A thin object leaves the fingers nearly shut, so consumers treatclosedas ambiguous. Report it anyway — it is still information.- Partially open is a verdict you should not give. A hand around a can and a hand halfway
to open are the same number; omit
staterather than bucketing it. holdingis worth omitting until you have measured it — and since BIN-499 it creates a belief rather than only confirming one. The brain no longer decides whether it is holding something; it reads your answer. So aholdingyou report on a gripper the brain never picked anything up with makes it tell its planner "you ARE holding something and its identity is UNKNOWN — look, or ask". That is the correct response to a real reading (it is how a failed grasp that left the object gripped finally becomes visible — BIN-210), and a threshold guessed too low turns it into a phantom object nobody can find. The R1 adapter ships with it disabled for exactly this reason, whilestate— whose geometry comes from the URDF and needs no calibration — works out of the box.- A non-finite reading is not a small one. The R1's sim publishes NaN load (no effort
controller); that yields no verdict, never
holding: false.
This is not hypothetical care. The two fields were read by binabik-world-state's posture
sentence and by the brain's reconciliation for weeks while no robot produced them — both
silently dead, because a missing field and a false one are indistinguishable to a consumer
that does not check.
What the brain does with your gripper answers (BIN-499)
Worth knowing while you calibrate, because it is what each verdict actually causes. The rule is the grippers decide whether; the brain's cache only names what: a load cell can report that something is held and never that it is a bobbin, so the label comes from the grasp that put it there and is subordinate to your reading.
| Your answer | What the brain does |
|---|---|
every side open |
drops the held-object belief entirely (BIN-396) |
one side open, another holding |
drops that side's belief only — sides are settled independently |
holding on a side it never grasped with |
asserts an unnamed hold and tells the planner to look or ask |
closed, no holding |
nothing — ambiguous, so the existing belief stands |
| no answer at all | falls back to the grasp this session ran, and drops the belief if none did |
Two consequences for an integrator. Omitting holding is safe — the last row means an
uncalibrated robot still works, because the brain trusts its own grasp instead; that is the
path the sim takes today. And closed is genuinely neutral, so reporting it costs you
nothing: the brain will not empty a hand because the fingers are shut, which is what makes it
safe to report on a robot that grips things its sensing cannot feel.
Report what you cannot measure as missing, not as fine (BIN-286)
joint_state is the clearest case, and the rule is general. If you do not have joint
limits — no URDF, no model, a joint that is genuinely continuous — omit lower,
upper, pct_of_range and at_limit and set limits_available: false with a reason.
Do not default at_limit to false. Every joint reporting "not at its limit" is
exactly what a healthy robot looks like, so a defaulted answer is indistinguishable from a
measured one, and the watchdog that should have flagged a jammed arm reports a clean bill
of health instead. The brain is built to handle "unknown"; it cannot handle a confident
wrong answer.
The same applies to a fault signal you do not have, a camera whose topic no publisher is writing to, and an object list you have not refreshed. Absent must be distinguishable from healthy — that is the single convention this contract cares most about.
fault_state: a clean bill of health is a stronger claim than a fault (BIN-308)
Fault reads are where the rule above is hardest to honour, because the healthy answer and the blind answer look identical from the outside — and a watchdog acts on them very differently. Four things make an implementation trustworthy.
Report per signal, not per tool. A robot can usually see some faults and not others, so
fault_state carries a key per readable signal and an unreadable map for the rest:
// a robot whose motor faults are readable and whose e-stop is not
{"ok": true, "fault": false, "faults": [],
"groups": {"left_arm": {"ok": true, "faults": []}, "torso": {"ok": true, "faults": []}},
"readable": ["fault"],
"unreadable": {"e_stop": "the emergency stop is a hardware latch with no state topic — a
pressed one is visible only as the driver feedback going silent",
"self_collision": "reported as a planning result (a rejected plan), not as a
continuously published state"}}
Every entry in unreadable needs a reason an operator can act on, because that string is
what the World State panel renders and what the planner is told. available: false with no
explanation is a dead end.
Never emit the signal you cannot read, even as false. This is the rule above, and it is
worth restating because a fault key is the one place the temptation is strongest: a consumer's
.get("e_stop") stops raising the moment you add it, and the cost lands on someone else, much
later, when a supervisor reads "e-stop not pressed" off a robot that has no idea.
The asymmetry: believe a fault on partial data; never a clean bill. If five actuator
groups report clean and the sixth does not answer, fault is absent with a reason naming
the silent group — the fault you are missing may be exactly the one that went quiet. But if
any group does report an error, say so immediately, even with the others missing: a found
fault is trustworthy on partial data. The two mistakes are not symmetric, so the answer is not
symmetric either.
A message you cannot interpret is not a clean one. A status message that arrives without the field carrying the error codes, or with entries in a shape you do not recognise, means unreadable — not healthy, and not an invented fault. This is the one that slips through: parsing defensively and then falling back to "no faults found" produces a confident all-clear out of a message you did not understand.
Mutation-test each of these. A fixture that always reports a healthy robot proves nothing about the guard — flip each condition and watch the verdict change, or the test is decoration (BIN-287's acceptance asked for exactly this, and the R1's adapter ships 15 such checks).
Where the raw signals come from is your business, and stays behind the seam
Fault signals almost always live on robot-specific topics or a vendor SDK. Bridge them
inside your adapter and publish only the contract's vocabulary — named body groups
(left_arm, torso, base), not topic names, and certainly not the introspection tools you
used to read them.
The R1's adapter reads six driver status topics through galaxea's ROS introspection server and
deliberately does not re-expose that server's tools on the contract: topic and node names
are the most robot-specific vocabulary in a stack, and publish_once / call_service /
send_action_goal are unguarded robot control — a planner holding them moves the robot outside
every guard the rest of this contract establishes. Raw topic names still belong in a
diagnostic reason string, which is where an operator needs them.
compute_grasp_joints must answer for every approach you advertise (BIN-293)
The skill picks an approach from the object's measured shape and then solves for it. If
your implementation answers only for some approaches and refuses the rest, every grasp
that routes to a refused approach fails before the robot moves — and it fails as
pre_grasp_failed / descend_failed, which reads like an IK or reachability problem
rather than a missing primitive. On the R1 this went unnoticed for weeks: only two_hand
was implemented, while the strategy pack routes anything narrower than ~0.18 m to side,
so most objects were unpickable and the failure blamed the arm.
Answer for each (approach, phase) pair the skill's FSM for that approach solves — on the
R1 that is side: pre + grasp, top: grasp, two_hand: pre + grasp. A phase you
genuinely do not implement should say so in reason, naming what you do have, so the
failure is self-diagnosing.
If your robot's grasp is a stateful chain rather than a stateless solve, bridge it in
your adapter rather than refusing: the solve call runs the chain's prerequisites and records
which step comes next, and the following plan_execute runs that step. That is what
r1-abstraction does for the R1's side/top chains, and it is the same shape as its
approach → approach_object bridge. Two costs to accept knowingly: the skill's
reachability gate weakens (an unreachable pose now surfaces at the move, not at the solve),
and chain steps that fuse gripper or attach actions will see the skill re-issue its own —
make sure those are idempotent on your hardware.
Do not use stage for this. plan_execute receives a stage label from the skill,
but it is telemetry-only and is never transmitted to the robot — it exists so two moves
with identical arguments are distinguishable in the debug stream. The distinguishing
information you can act on arrives at compute_grasp_joints, which is given both approach
and phase.
attach_object: the exemption must cover every link the object touches, on both arms (BIN-166)
attach_object(object_id, side) takes one side, but the exemption it installs — the
AttachedCollisionObject's touch_links — is what stops the held object from putting
the robot's own start state in collision, so it must list every link the object
physically overlaps, including the other arm's. A two-hand grasp holds the object between
both grippers while attaching it to one side; attaching per-side would change nothing,
because the exemption is per-link, not per-side.
Get it wrong and there is no error at attach time — the failure surfaces one call later, as
an instant plan rejection that looks like a grasp problem. On the R1, touch_links named
*_realsense_link for the wrist cameras, which r1pro_description had renamed to
*_D405_link (keeping the old mesh filename, so the stale entry looked right). The wrist
cameras were therefore never exempt, a box wide enough for a two-hand grasp penetrated both
housings, and the following lift_box was refused in 0.4 s:
PlanningRequestAdapter 'CheckStartStateCollision' failed, because '2 contact(s) detected :
left_D405_link - picked_obj_2, picked_obj_2 - right_D405_link, '. Aborting planning pipeline.
START_STATE_IN_COLLISION
Two rules for an integrator: spell the links exactly as the URDF names them (a dead name
fails silently), and re-grep the list whenever a link is renamed. Listing both the old
and new spelling is safe — MoveIt ignores a touch_links entry naming a link the model
doesn't have — and keeps the exemption working across URDF versions.
detach_object releases the attachment, not the object — pair it with a removal (BIN-170)
A detach must also delete the object, and your adapter has to say which it does. MoveIt's
detach does not remove the body: it re-adds it to the collision world at its current pose.
The world copy carries none of the AttachedCollisionObject's touch_links, so the
exemption the warning above is about disappears at exactly the moment the arms are still
wrapped around the object — turning a body the arms may touch into an obstacle they may not.
The same rap-1 geometry that produced the two-contact rejection above produced eleven
once detached, every one of them a link the attachment had exempted:
PlanningRequestAdapter 'CheckStartStateCollision' failed, because '11 contact(s) detected :
left_D405_link - picked_obj_2, left_arm_link5 - picked_obj_2, left_arm_link6 - picked_obj_2,
left_arm_link7 - picked_obj_2, left_gripper_finger_link1 - picked_obj_2, … ,
picked_obj_2 - right_gripper_link, '. Aborting planning pipeline.
Three rules for an integrator — all three are now satisfied by the reference stack (BIN-173), so the R1 adapter is the worked example rather than a counter-example:
- Expose
remove_object_from_planning_scene(object_id, attached)— the inverse ofadd_object_to_planning_scene. Without it an L3 skill has no way to clean up after a detach, and its only honest option is to never detach. Make it idempotent: MoveIt treats removing an absent object as a no-op, which is what lets a skill call it from afinallypath that cannot know what was added.attachedselects the stored id —attach_objectfiles the body underpicked_<object_id>, a plain world object under its own — and getting that wrong fails silently, because the no-op still reports success. - Make
detach_objectrelease and remove, or say loudly that it does not. The R1 adapter takes the second route: itsdetach_objectdocstring now states that the body lands in the world and keeps colliding, and names the two counterparts. Internallygalaxea_agentalways knew the difference —_td_cleanup_scenepairsdetach_collision_objectwithremove_collision_object_from_sceneand un-allows the ACM entries, calling out that skipping it "would otherwise ORPHAN the object" — but only the first half had ever been exposed as a tool. - A detach is not a physical release. If the grip is an arm squeeze rather than finger
actuation, opening the grippers does not let go either. Expose a real release
(
release_boxon the R1: widen the arms, detach and remove, clear the octomap, home) rather than leaving a skill to compose one it cannot validate — the widening plan it would have to write starts inside the object's own collision volume, so composing it at L3 is not merely inconvenient but unplannable.
An 0.4 s plan_execute return is the signature: a plan that executed takes seconds, so a
sub-second failure is a planning rejection. Read move_group's log for the reason before
theorising — the contact list names the offending link pair outright.
2b. Layer 5 — perception¶
| Tool | Args | Returns | Meaning | |
|---|---|---|---|---|
| ● | locate_3d |
query |
{success, n_objects, best:{label, x, y, z}, objects:[…]} |
Ground + 3-D localize objects matching a phrase. |
| ○ | perceive |
prompt, phase (coarse|fine) |
{objects:[{pose_odom, dims, face_normal, cluster?, dist, is_top_of_stack}]} |
The stateless grounding primitive an L3 grasp FSM drives — returns data, stores nothing. |
| ● | grasp_pose |
query, target_frame |
{success, object:{x, y, z, radius}, frame_id} |
Precise close-range grasp geometry (fitted box). |
| ● | verify_held |
side (or approach) [, prompt, criteria] |
{held, side} |
Confirm the gripper actually holds something (finger-load or VLM). Read held only — never a success flag that merely says the check ran. criteria is the caller's definition of "held" — see the rule below. |
| ○ | where_to_stand |
target_query |
{success, target:{x,y,z}, stand:{x,y,yaw_deg}} |
Where to position the base to reach an object. |
| ○ | locate_surface |
name |
{ok, pose, …} |
Find a surface to place on. |
| ○ | list_cameras |
– | {ok, cameras:[{name, streaming, has_depth, rgb_publishers, age_s, reason}], complete?} |
What cameras this robot has. Free. Call it instead of assuming a camera exists — see the camera rules. |
| ○ | get_frame |
[camera, annotated] |
{ok, camera, image_jpeg_b64, width, height, stamp, annotated?} |
One camera's frame snapshot — image only, cheap enough to poll. An unknown camera fails, naming what exists. annotated is optional and paid, and its echo is not optional — declare it even if you refuse it, because an undeclared argument is dropped in silence. See the rule below. |
| ○ | capture_scene |
[query, max_objects, camera] |
{ok, camera, image_jpeg_b64, width, height, segmenter, segmenter_cost_class, objects:[{id, label, score, bbox:[x0,y0,x1,y1], polygon:[[u,v]…], centroid_uv, point_3d:{x,y,z,frame}}]} |
Detect + segment the scene into enumerated objects, each with an open-vocab label and a 3-D pick point. |
| ○ | pixel_to_3d |
u, v[, target_frame, camera] |
{ok, camera, x, y, z, frame} |
Deproject a pixel to a 3-D point from that camera's own organized cloud. |
| ○ | locate_marker |
marker_id[, camera, target_frame] |
{ok, camera, marker:{id, pose_in_target, position, …}, detector, reason} |
Where one fiducial marker is — the docking datum. reason is a stable code and the split is the point: no detector installed and no tag in view are the same empty answer and completely different actions. Pose from mono corners + intrinsics + the known tag size, not from depth. |
| ○ | detect_markers |
[camera, target_frame] |
{ok, camera, markers:[…], detector, reason} |
Every marker in view, for when the id is not known — surveying a machine, or finding that a dock was taught against the wrong tag. An empty list always travels with a reason, because an empty list on its own reads as "nothing wrong". |
| ○ | grasp_from_pixel |
u, v[, side] |
same shape as grasp_pose |
Grasp geometry for the object under a clicked pixel. |
| ○ | stream_start |
offer_sdp, viewer_id[, viewer_ip] |
{ok, sdp, message} |
Answer one browser's WebRTC offer, so the head camera is a real ~30 fps stream instead of a get_frame poll. Never raises — {ok: false, message} when it cannot serve one, so the brain falls back to get_frame with no special-casing. See the media plane. |
| ○ | stream_stop |
viewer_id |
{ok, message} |
Release one viewer's encode. Idempotent: an unknown viewer_id still answers {ok: true}. |
| ○ | robot_state |
– | {ok, issue, reasoning} |
Surveillance props verdict over your own properties. ok: false with a reason in issue for a fault that should stop a plan; report-only observations go in reasoning. It must be able to fail — see the watchdog — and it must not fail merely because a signal is missing. The raw reads belong in fault_state. |
Cameras¶
Three rules, and each of them is a bug we shipped (BIN-302).
1. Honour the camera argument, or do not accept one. get_frame(camera) was accepted and
ignored at every layer — one subscription, one buffer — so asking for left_gripper returned
the head camera. That is worse than an error: a wrong answer that looks right, handed to a model
reasoning about whether the gripper is holding something, with nothing downstream able to tell.
An unknown camera must fail and name what exists. Never substitute.
2. Say why a camera is silent — "no publisher" is not "no frame yet". A camera pointed at a
topic nothing publishes and a camera that has not warmed up look identical from outside and mean
opposite things: fix the topic name, versus wait. BIN-256 was weeks spent reading one as the
other, on a robot whose head-camera topic had 0 publishers and 1 subscriber — our own node.
Report the publisher count, or at minimum distinguish the two in reason.
3. Depth belongs to the camera that has it. pixel_to_3d deprojects against an organized
cloud. A pick made on a gripper image and deprojected against the head cloud produces a
confident, wrong 3-D point — and that point drives an arm. A camera with no cloud of its own
must refuse rather than borrow one, and has_depth must say so up front so a UI can disable
click-to-pick instead of offering something that silently lies.
The degraded form. A robot with one camera reports exactly that: one entry, has_depth
truthfully, and complete: false if it cannot enumerate. That is a complete answer, not a
limitation to hide — and the complete flag is what stops a consumer reading "one camera
listed" as "one camera exists". If a fallback path can only serve some cameras (the R1's
adapter falls back to a tool that serves head only), it must refuse the others rather than
answering them from the camera it does have.
4. If you implement annotated, echo it — and if you do not, declare it anyway and refuse it.
get_frame(annotated=true) asks for the labelled overlay: the frame with the scene inventory's
object ids drawn on it, so what a model says lines up with what a skill acts on. Producing those ids
means running the grounder, which makes it a capture on the same bill as capture_scene — and
the plain and annotated payloads are otherwise byte-for-byte the same shape, which is the problem.
A consumer cannot tell them apart, so it cannot know whether it was billed, and it cannot know
whether the ids it was promised are on the image.
So the answer must carry annotated: true when the overlay was produced. Absent (or false) means
this is a plain frame, whatever was asked for. binabik-world-state keys its capture budget on
exactly that echo — it charges on delivery, not on request — and this rule exists because the R1
failed it in the quietest possible way (BIN-536): the adapter's get_frame(camera) never accepted
an annotated parameter, FastMCP silently drops arguments its schema does not name, and so for
months every annotated request returned a plain frame while the brain told its planner the object
ids were there. Accepting an argument you ignore is rule 1 in this section, one argument over.
Not declaring it is not the safe option — it is that bug. This rule used to end "and if you do
not implement it, do not accept one", which cannot be complied with: FastMCP builds each tool's
arguments as a pydantic model with the default extra="ignore" and the MCP SDK switches the
low-level schema validation off, so an undeclared annotated is dropped silently — no error,
no warning, nothing in any log. Leaving the parameter out does not refuse the request; it hides it
(BIN-555). The general case — every undeclared argument on every tool, and the one-line guard that
makes it audible — is a convention of its own (BIN-557).
So there are two compliant answers, and silence is not one of them:
- Implement it — run the grounder, draw the ids, return
annotated: true, and expect to be charged for a capture. - Refuse it — declare the parameter, answer with the free plain frame, and say
annotated: falsewith a reason naming what the caller should use instead. This is what the R1 does (BIN-555):r1-abstraction'sget_frameforwards the flag tobinabik-r1-vision, which declares it and refuses it in turn, and delivery is read off that service's echo rather than off the request — so the plain frame comes back with a reason pointing atcapture_scenefor the same ids as data. Both layers state the refusal; neither infers it from the other's silence.
If you implement it, the annotated image and the object list must come from the same capture, in
one payload. Our scene core numbers objects positionally — "id": i over the grounder's reply
order — so an id means something only inside the payload that produced it, and two captures of an
unchanged scene can renumber everything. An overlay served separately from the inventory a consumer
holds is therefore worse than no overlay: the model grounds its language to ids the skill will
never act on (BIN-376). Either return the objects you drew alongside the image, or give the ids
whatever cross-capture stability lets a consumer join the two.
The R1 refuses on purpose, and this was decided — please do not re-file it (BIN-558)
The overlay stayed unbuilt long enough to look like an oversight, so here is the answer, to save the next person the survey. It is not being built. Nothing in this rule changes for your robot — if yours produces a labelled frame natively, implement and echo. But do not file the R1's as missing work.
Nothing consumes one. Three candidates, all empty: the brain's operator-gated
POST /api/world_frame/annotated has no caller in the frontend; the surveillance watchdog
documents that both its reads are free and annotated is never True; and the planner's
request path is unreachable on main — images to the planner are off by default, and the one
site that attaches a frame passes a trigger that is not in the set the paid overlay is gated
on.
The ids it would draw are not the ids anything acts on. This is the part that does not
change with effort. Every action in the contract is addressed by text: perceive(prompt),
locate_3d(query), where_to_stand(target_query), and L3's grasp("the red bobbin"). The
object_id threaded through the R1's grasp bridge is galaxea's own detection handle, minted
fresh from a text prompt — a different id space from the scene core's positional index. So a
model that successfully grounds "object 3" still has to translate back into a phrase to do
anything with it, and the phrase is what the label gave it already. Marking up an image pays
off when the mark is what the model emits; here nothing accepts one.
The human already gets a better overlay than ink. The Visual Command view draws the
polygons and labels client-side, as SVG over the live frame, from the capture_scene payload
that produced them: toggleable, hit-testable for pick and arrange, restyled when the scene goes
stale, dashed when the outline is a box stand-in rather than a mask. Burnt-in pixels are none
of those things and cannot be turned off.
And the planner's text is not short of grounding — it is short of pixels on purpose. It
receives label[id] at (x, y, z): a 3-D centroid in a robot frame, which is what the robot
acts in. centroid_uv, bbox and polygon all exist in the snapshot and are deliberately not
printed. If the list↔image join ever does turn out to matter, printing centroid_uv costs ~10
tokens per object and no grounder run — try that before spending a capture on ink.
What would reopen this: a tool that accepts a scene-object id as its target, or a consumer that needs a rendered frame it cannot draw itself. Neither exists today.
Your robot does not need a VLM (BIN-328)
observe_task used to be on this list: the robot ran its own vision-language model over its
head camera and returned a sentence — "the gripper looks empty" — to the brain's watchdog.
It has been removed from the contract, and a robot integrating today should not build it.
The brain judges the frame itself now, because only the brain can cross-check it. A verdict
formed on the robot cannot be reconciled with at_limit or with "these joints have not moved
in 8 s" — those measurements live in the world snapshot, on the other side of the wire. The
finding that matters is "the arm has not moved in 8 s and the image shows the gripper
still open", and it can only be written by something holding both halves.
What your robot owes the watchdog is therefore the raw material, not the opinion: a
camera frame through get_frame, and whatever body state you can report. That also means one
less model, one less API key and one less per-robot cost centre to configure.
Both observer tools are advisory and fail-safe (return ok on internal error) so a
flaky observer never aborts a running plan. The scene trio (get_frame /
capture_scene / pixel_to_3d) is what powers the Visual Command view — §3h.
segmenter names the segmenter that served the frame (per-pixel masks vs the
bounding-box fallback) so the UI knows the overlay fidelity, and
segmenter_cost_class (metered / fixed) tells it whether those masks are being billed
(BIN-136) — metered means calling the segmenter again costs more money, fixed means it
runs on hardware already paid for, on-robot or not. It said local/cloud until BIN-405,
which described location rather than billing and so labelled a remote self-hosted box
local. Send the current pair: there are no compatibility aliases. A brain that does not
recognise the value logs it and reports the tier as unknown rather than guessing — guessing
fixed is how a billing GPU comes to be displayed as the free one. An implementation resolving a fallback chain may add segmenter_tier /
segmenter_detail / segmenter_error, and get_frame may carry warm-up state
(binabik-r1-vision reports segmenter_warm: cold/warming/ready/failed,
BIN-124) so a slow first capture_scene is distinguishable from a broken one.
Those extra fields are worth adding if your chain has more than one tier: the brain
reads them for GET /api/segmenter, which drives the operator's segmenter chip, the
box-fallback notice and the paid-GPU warning
(Using the brain shows what the operator sees). get_frame
may also carry a segmenter_info object ({tier, segmenter, cost_class, detail, skipped[],
error}); skipped — one line per tier you passed over and why — is what the UI shows to
answer "why do my masks look like boxes", and it is the only place that reasoning can come
from. Report nothing rather than a guess: the brain renders a blank chip for an unknown
segmenter instead of claiming a tier.
Diagnostics — sim_diagnostics, ik_diag, reach_probe, system_stats, … — are
implementation aids, not part of the contract; expose them freely.
2c. Return fields the brain reads¶
Compiled plans thread tool results into later steps and gate on them, so the field
names are load-bearing: a pick result saved as pk is gated with expect: pk.held,
a grasp saved as gp with expect: gp.held, a locate_3d saved as loc with
expect: loc.success, and a later step releases with the same arm via
place(side=gp.side). Return the authoritative flag plus the task-specific booleans
(held, reachable, released, …) and the values (side, coordinates, dims) later
steps consume. Match the reference servers' names and the stock plan templates work
unchanged.
Worked example — one grasp (L3) return, and who reads each field:
{"ok": true, "held": true, "side": "left", "approach": "side",
"attempts": 2, "reason": "", "object": "coke can"}
| Field | Example | Who consumes it |
|---|---|---|
ok |
true |
the plan machine — did the call itself complete |
held |
true |
the success gate: expect: gp.held (ok == held for grasp) |
side |
"left" |
a later place(side=gp.side) — release with the same arm — and the GUI's per-gripper hold display. "left" \| "right" \| "both"; see the note below |
approach |
"side" |
telemetry / self-improvement (which strategy won) — and the two-hand tell-tale (below) |
attempts |
2 |
telemetry — how hard it worked |
reason |
"missed" |
on failure, why → lets the mission brain replan (not_found, unreachable, no_strategy, unsupported_approach, tool_error, <state>_failed, missed, timeout, or the recovery tier's own give-up reason) |
object |
"coke can" |
present on success; echoes what was grasped |
The same rule holds one layer down: pick → {ok, held, side, approach}, locate_3d →
{success, n_objects, best:{…}}, grasp_pose → {success, object:{x,y,z,radius}},
verify_held → {held}.
Say which gripper — and say both for a two-hand grasp
side is not only plumbing for the next step: it is what the brain shows the operator
per gripper, and what it clears when the object is placed
(Using the brain).
- A grasp that uses both grippers must report
side: "both"(what grasp-service's box strategy does) or name it inapproach/style(two_hand). The brain treats the approach as authoritative over a single side, because a two-hand FSM typically closes both grippers and attaches the object to just one — reporting only that one under-reports the hold, and a box then shows as held in one hand. - A
placeshould echo thesideit released.{ok, released}alone is accepted — the brain falls back to the side it dispatched the call with — but with two different objects held and no side anywhere, it deliberately clears nothing rather than guess. - Omitting
sideon a successful pick is tolerated, not free. The hold is kept and shown as "gripper unreported"; it just can't be attributed to a hand, and a laterplace(side=…)has nothing to be corrected to.
Which tools the brain waits for — your manifest says, and nothing else does (BIN-543)
A blocking tool is one the brain holds the plan on until it returns. Which of yours are
blocking comes entirely from your attach manifest: each server's blocking: list plus the
manifest's blocking_extra:. robots/r1.yaml is the worked example.
This note used to say the brain "treats a fixed list of tool names as blocking" which the
manifest could extend. That was true, and it was the wrong design: the list lived in
gen_mcp_config.py on the brain host and held 26 R1 tool names, so a second robot that
used the canonical names inherited R1 behaviour by accident and one that did not got nothing,
with no line in its own manifest to explain either. The list moved to robots/r1.yaml; the
launcher now contributes none. Naming a tool canonically no longer makes it blocking — list
it. For a running instance the generated config/tools/mcp.yaml (blocking_tools) is what
is actually in force.
stop is deliberately not blocking, because it is a barge-in.
2d. Conventions (part of the contract)¶
- Frames: base pose in
map; close-range grasp geometry inodom(drift-free); every pose/point carries an explicitframe_id. - Units: positions in metres, angles in degrees (
yaw_deg,turned_deg), CCW-positive (REP-103). Most ROS tooling underneath you is in radians — nav2'snavigate_to_posetakesyawin radians, and handing it ayaw_deggets you a robot facing east: the unknown key is dropped in silence andyawfalls back to its default (the rule below). This paragraph used to say the opposite — "MCP tool schemas areadditionalProperties: false, so it is a hard validation error, not a silently ignored key" — which is measurably untrue of the FastMCP servers this stack is built from, and is the belief that let BIN-555 run for months. Convert in your adapter, at the seam, and nowhere else; the same goes for reshaping a nested payload (nav2 answersget_robot_poseas{position:{…}, orientation:{yaw, yaw_degrees}}) into the flat contract shape. Both of those mismatches shipped inr1-abstractionand made named navigation and "save where I am" impossible for weeks (BIN-211, BIN-212), because the fakes in its tests accepted whatever the adapter sent. Test a delegate's call shape against its real signature. - Sides:
"left"/"right"— spelled out, neverl/ror an index. -
An argument you do not declare is dropped in silence — so declare every argument you accept, and turn the warning on. FastMCP builds each tool's arguments as a pydantic model with the default
extra="ignore", the MCP SDK switches the low-level server's JSON-schema validation off (call_tool(validate_input=False)), and pydantic emitsadditionalProperties: falseonly forextra="forbid"— so nothing at any layer objects to an argument your signature does not name. No error, no warning, nothing in any log: the call succeeds, and answers a different question than the caller asked.This is the quietest way to break the contract, and it has happened three times at this seam —
cameraaccepted and ignored (BIN-302),carrydropped so a two-hand box attached as gripper-carry (BIN-504),annotateddropped so a documented, budgeted overlay turned out never to have existed (BIN-555). All three were found by reading two repos side by side, never by a failure, and none of them looked like a bug from either end.So: the schema you advertise is the contract — if you accept a parameter, declare it, and honour or explicitly refuse it (rule 1 of §2b); if a caller sends one you do not declare, one of you is wrong and it should be audible. In a Python server that is one line, from
robot-mcp-kit(BIN-557):from robot_mcp_kit import warn_on_undeclared_arguments mcp = FastMCP("my-robot") warn_on_undeclared_arguments(mcp)It warns and never refuses — a hard failure on an extra keyword would take a live consumer down at deploy time for a stale argument nobody misses — and warns once per tool + argument per process, so a polled tool does not flood the log. In another language, do the equivalent: compare the incoming keys against the tool's declared properties and log what you dropped. And on the calling side, the lesson is the same one the units convention above ends with: test a delegate's call shape against its real signature, because a fake that accepts whatever you send will never tell you.
Test it over the wire, and do not let the fixture install it. Two traps, both measured while wiring this into the servers (BIN-557/BIN-563), and both of them produce a guard that is green in CI and inert on the robot:
FastMCP.__init__registers the JSON-RPC handler with the boundself.call_toolit held at that moment, so replacing the instance attribute afterwards is invisible to every call that arrives over a socket. Measured by deleting the wire half of the kit's guard: 3 tests that calledmcp.call_tool()directly kept passing while 11 wire tests failed. So call through the registeredCallToolRequesthandler — or a real client session — not through the method.- A fixture that calls
warn_on_undeclared_arguments(server.mcp)to reset state will install the guard your server module forgot, and every test stays green while the deployed service drops arguments in silence. Read the live memo off the server instead (server.mcp._binabik_warned_undeclared_args) and fail when it is absent.
Then check both halves by mutation, because neither is self-evident: delete the call from your server and watch the test go red, and make a tool stop declaring an argument it accepts — the actual production shape — and watch the guard name it. Rename the parameter and alias it in the body; renaming it to
_nametripsInvalidSignatureat collection, so the suite goes red for an unrelated reason and the kill is spurious. And assert the level (record.levelno == logging.WARNING), not merely that a record exists: a demotion toinfopasses any unlevelled assertion while an operator's log filter never shows it again.Deleting the call does not prove your tests reach the wire, though — a third mutation does. With the memo assertion above in place, removing
warn_on_undeclared_arguments(mcp)reddens the whole file through the fixture, whether the cases below it call the wire or the method, so it cannot tell the two apart. The mutation that can is to wire the guard and then put the originalCallToolRequesthandler back, leaving only the in-process patch:from mcp.types import CallToolRequest original = mcp._mcp_server.request_handlers[CallToolRequest] warn_on_undeclared_arguments(mcp) mcp._mcp_server.request_handlers[CallToolRequest] = original # wire half undoneThat is production's exact failure mode, and a wire-level suite fails on it while the memo assertion still passes. Measured on
binabik-world-state(BIN-565): 5 of 7 cases went red, each with zero records captured — the two that survived being the memo assertion (the guard is installed) and the case asserting a well-formed call says nothing (still true). Restore the handler afterwards; the mutation is a check, not a change.On a server with paid tools, the guard's own test must not be what spends. Drive it against a free call and assert on what was called —
assert not robot.named("capture_scene")— rather than on what came back: a capture whose result was discarded looks exactly like one that never happened.binabik-world-state's cases pick arguments that are free in both directions, dropped or honoured (a misspeltannotatdyields the free plain frame; a misspelttiggerfalls back to the free default read), so no ordering of the guard and the tool can reach the grounder. - Say what a parameter looks like, in the schema —examples. An operator building a plan in the brain's Programming view gets a form generated from the schema you advertise, and the brain holds no per-skill code — that rule is what stops the form rotting the first time you change a signature. So an empty box shows JSON Schemaexamples(falling back todefault) as its grey placeholder, andobject: strbecomesobject: "the red bobbin". Put one on every parameter whose value is a phrase, a name or a magnitude an operator would otherwise have to guess. In a FastMCP server that is one annotation:async def perceive( prompt: Annotated[str, Field(examples=["the red bobbin on the left shelf"])], ) -> str: ...An example is a suggestion: do not promote it to an
enumunless the set really is closed, because anenumrefuses every other value.grasp's three approaches areexamplesfor exactly that reason — the strategy pack also carries debug-only variants an operator may be asked for by name. - A field whose choices are live data names its source —x-binabik-choices. Some choices do not exist until run time:navigate_to_named(name)should offer the places this robot has been taught. Rather than the brain carrying a table of which parameter means what, the schema says it, and the brain resolves the named source and publishes the answer as an ordinaryenum— so the editor shows a dropdown and needs no release when a place is taught:async def navigate_to_named( name: Annotated[str, Field(examples=["shaft-station"], json_schema_extra={"x-binabik-choices": "waypoints"})], ) -> str: ...Source Filled from Use it on waypointsyour list_waypointsa parameter that must name a place the robot already knows Three properties worth knowing.
x-is JSON Schema's own extension prefix, so every other consumer ignores it and a robot that never heard of this is unaffected. A source this brain does not know — including a typo — degrades to a plain text box, silently and by design, so a schema test in your own repo is what catches a misspelling. And mark only the fields that must name something existing:save_waypoint(name)deliberately carries no marker, because that is where a name is created and offering only the current places would make teaching a new one impossible. - Success: one authoritative boolean per return —okon the L4 motion/IO tools,successon the L5 grounding tools (locate_3d,grasp_pose,where_to_stand). Accept and, where cheap, return both; callers in this stack readok or success. For a grasp,held— notok— is the truth. - Errors: on failure return the flag asfalseplus a shortreason(a stable token likenot_found,unreachable,timeout) or a humanmessage. Never raise through MCP for an expected failure, and never return a wall of stack trace: the planner reads this text. - A capability you do not have is an absence, not a default. Where your robot cannot answer a primitive, either do not implement the tool or return the flag asfalsewith areason. What you must never return is the plausible-looking value:joints: []from a robot that cannot read its joints,at_limit: falsewith no URDF,ok: truefrom a health tool that checks nothing. The world service in front of your adapter (binabik-world-state) turns each read into one uniform block —{"label": "Robot motor states", "available": false, "reason": …}— and every consumer, the planner, the L3 skills, the supervisor and the operator's World State panel, renders an unavailable block as Not available. A plausible default defeats that in the one way that matters: downstream nobody can tell "this robot reports no faults" from "this robot cannot see faults", and they are acted on differently. The two honest forms are also read differently and both are useful — an unimplemented tool reads as an integration gap ("this robot does not implementjoint_state()"), anok: falsereads as a failure to go and look at. Pick whichever is true. - Blocking + progress: motion tools block until done; long ones emit progress. Send MCP'sprogressas seconds since this invocation began, monotonic, restarting at 0 for each new call — the brain surfaces it verbatim aselapsed_sand derives a mission's phase timing from the differences (using-the-brain.md#mission-time). A counter works for a progress bar and is refused by the time breakdown, which cannot tell3meaning "step 3" from3meaning three seconds. Today this is a convention the brain validates rather than a contract it can check; BIN-492 tracks stating it as one. -stopis out-of-band: it must be serviced preemptively, not queued behind the motion it is meant to cancel. A robot whosestopwaits for the current move is not contract-compliant. It is also the one tool whose absence is reported loudly rather than degraded around — see the naming rule directly below. - A dozen names are load-bearing; the rest are free. The planner picks most of your tools out of their docstrings, so what you call them barely matters. But about a dozen the brain resolves by name in code, and for those the spelling is the interface: a robot that names one differently, or omits it, silently loses that feature. They arestop,clear_stop,get_frame,capture_scene,pixel_to_3d,turn_by,drive_by,strafe_by,list_waypoints,save_waypoint,delete_waypoint,get_pose— enumerated with their exact consequences inBY_NAME_TOOLS(robot-voice-chat/backend/src/robot_voice_chat/agent/executor.py), which a test keeps in step with the code. Resolution accepts bare or server-prefixed (stop↔rap_robot.stop), and nothing else. Most absences degrade quietly-but-visibly (one notice, or a disabled control that says why);stopis the exception and is reported every single time it cannot be resolved or fails, because the brain-side half of a stop — cancelling the in-flight call, pausing the mission, saying "Stopped" — happens whether or not your robot heard it, so a silent failure there reads to an operator as a halted robot that is in fact still executing its last goal (BIN-300). - Transport: MCP over SSE, bound0.0.0.0on the tailnet, bare tool names (§1). - Payloads are JSON: tools return JSON text (json.dumps(...)of the dicts above); keep keys stable — they are an interface, not log output. A tool that answers in English is not compliant, however friendly the sentence, and normalising a robot that does is your adapter's job, not the brain's.ToolClient.call_tool— what every consumer in this stack uses — requires a JSON object and raises on anything else. So a prose answer does not degrade, it inverts:nav2_mcp_serverreturns"Successfully spun robot by -0.79 radians", and for months every successful base move, waypoint navigation and stop-cancel reached the brain asError executing tool turn_by: tool 'spin_robot' did not return JSONwhile the robot did exactly what it was asked (BIN-417). Where you are wrapping a server you do not own and cannot change, read it withcall_textat the seam and build the contract shape there — and take the verdict from the exception, not from the wording. A robot that returns a sentence on success and raises on failure has drawn the line in a usable place; a consumer that instead greps for"Successfully"puts the verdict one upstream copy-edit away from silently flipping.
Concrete examples of the frame + unit conventions in tool payloads:
// get_pose("map") — base pose: metres, degrees, CCW-positive (REP-103)
{"ok": true, "frame": "map", "x": 2.14, "y": -0.37, "yaw_deg": 90.0}
// grasp_pose(query, target_frame="odom") — close-range geometry in the drift-free frame
{"success": true, "object": {"x": 1.02, "y": 0.15, "z": 0.74, "radius": 0.03},
"frame_id": "odom"}
// pick(...) — every point carries an explicit frame_id; `held` is authoritative
// call: {"object_x": 1.02, "object_y": 0.15, "object_z": 0.74, "radius": 0.03,
// "frame_id": "odom", "approach": "side", "side": "left"}
// return: {"ok": true, "held": true, "side": "left", "approach": "side",
// "finger_width": 0.041}
2e. What the skill layer expects from your primitives¶
An L3 skill service (grasp-service is the worked example) runs a state machine over
your primitives and holds all of the run's state itself. That imposes four
requirements on the layer you implement — get these right and any skill written for
another robot works on yours:
- Stateless. Each primitive takes explicit inputs and returns data; nothing about
this object is remembered between calls.
perceivereturns the pose/dims/face normal, and the skill threads them intocompute_grasp_joints,approach,plan_execute. (The retired R1 design stored the object inside the robot at adetect_*step, which coupled call order to hidden state — that is exactly what the contract forbids now.) The MoveIt planning scene is legitimate world state, mutated only through the explicit scene calls in §2a. - Self-verifying and honest. A primitive checks its own effect and says so; the
skill trusts
heldfrompick/verify_heldand nothing else. A failure returnsfalse+ areason— it does not retry internally on the skill's behalf, and it does not report success optimistically. - Composable and re-drivable. Recovery re-calls single primitives with tweaked
arguments — other arm, larger standoff, a different
approach— so every primitive must be safe to call again from the current physical state, in an order the skill chooses. - Budgeted.
grasp-serviceallows 120 s per primitive call (GRASP_CALL_TIMEOUT_S, terminal — a hung call is not retried) inside a 480 s whole-grasp deadline (GRASP_DEADLINE_S, checked between states). A primitive that cannot finish in ~2 minutes must return a failure rather than hang. One exception by design: a primitive that owns an internal search (compute_grasp_joints' roll-search) governs itself with its own budget and is never wrapped in a blind outer retry.
Default → override → learn. Put your robot's default behaviour inside the
primitive (pick with no approach/side does the sensible thing), and accept
overrides on the same arguments so the skill's recovery tier can try something else
without new tools. The skill then stores what won and, over time, promotes it to the
default — the learning loop lives above your layer, and the only thing you owe it is
overridable parameters + honest results.
Minimum vs richer grasp surface. The only grasp tool a robot must implement is the
object-centric pick (§2a): a robot exposing just pick works end to end, it simply
forgoes step-level recovery (the skill falls back to whole-pick retries with a
different approach/side). Exposing the decomposed stateless primitives (perceive,
compute_grasp_joints, plan_execute, approach, verify_held, the scene calls) lets
the skill run its FSM as your tuned sequence and lets recovery re-drive one step.
Advertise which of these you expose in your prompt://system (§3c). The deeper
design — the FSM, strategy selection by object dimensions, the recovery tier, the
learn-back store — is the skill's, not yours:
grasp-service spec.
2f. What the R1's adapter actually exposes¶
r1-abstraction (:9220) is the reference implementation, and a useful reality check on
the set above — it is a pure MCP client that presents the canonical names over
galaxea_agent's r1_manipulation / perception / nav2 tools (delegation map in
config.py, reshaping bridges in adapter.py), filling the gaps binabik owns:
- Stateless primitives (what
grasp-service's FSM drives):perceive,compute_grasp_joints,plan_execute,approach,tilt_for_depth,verify_held, plus the scene group (add_object_to_planning_scene,allow_arm_vs_object,allow_arm_vs_octomap,attach_object,detach_object,remove_object_from_planning_scene,release_box,clear_octomap) and the base jogs (turn_by,drive_by,strafe_by). The last two of the scene group are what BIN-173 added, so the three release rules above are satisfied here rather than merely prescribed —release_boxis the physical release (widen the arms, detach and remove, clear the octomap, home) andremove_object_from_planning_sceneis the idempotent second half of a detach.grasp-servicenow drives both on a retry (BIN-170), under two rules the skill can state — never release an object whose lift succeeded, because that drops it from lift height; and treat a half-completed release as a refusal — but neither has run on hardware, and a wrong release drops a box. - Canonical L4/L5:
pick,place,move_arm,goto_named,set_gripper,gripper_state,go_to_stance,navigate_to_named,list_waypoints,save_waypoint,delete_waypoint,stop,clear_stop,locate_3d,grasp_pose,where_to_stand,locate_surface,get_frame,capture_scene,pixel_to_3d,robot_state,joint_state,ee_pose,fault_state, and — since KOE-36 —locate_marker,detect_markers,dock_to_marker,undock,save_dock,list_docks,dock_status. - Fault reads (BIN-308 §1):
fault_statebridges the R1 Pro driver's sixhdas_msg/FeedbackStatustopics through galaxea's ROS introspection server, using two read-only tools and re-exposing none of them. On the R1fault(driver motor faults) is readable ande_stop/self_collisionare not — both are absent with the reason, per the rule above. On the Gazebo sim those topics do not exist at all, sofaultis correctly absent there and the read only lights up on the physical robot. - Gaps binabik added on top of galaxea: named waypoints (the R1 has no place store —
the adapter keeps its own JSON file),
go_to_stance, and the preemptivestop. - Deviations, documented rather than faked: there is no
navigate_to(x, y, yaw)— deliberately, because the R1's map frame is rebuilt at every bring-up, so a coordinate the planner invents means nothing; places go through the waypoint store. (get_poseis implemented, over nav2'sget_robot_pose; it was long listed here as impossible on the grounds that base pose is "TF-internal", which was simply wrong and is what left the brain unable to save where the robot stood — BIN-212.)verify_helddeliberately returns galaxea's VLM check as{held}and drops itssuccessflag, which once produced a false positive on a missed grasp. - Scene capture is delegated:
capture_scene/get_frame/pixel_to_3dgo tobinabik-r1-vision(:9230,R1_VISION_URL), and when that service is down the adapter falls back to galaxealook— the frame comes back with an emptyobjectslist rather than an error. Vision is opt-in (VISION_ENABLE=1 r1ctl up) because everycapture_sceneruns a paid Gemini grounding call. - So is docking (KOE-36): the five docking tools go to
binabik-docking(:9250,R1_DOCKING_URL), andlocate_marker/detect_markersto the same vision service as scene capture. Both are opt-in (DOCKING_ENABLE=1/VISION_ENABLE=1), and when either is down the adapter answers{ok:false, code:"service_unavailable"}rather than raising — a robot without a docking service is a perfectly good robot, and the L3 skill above already branches onok. There is no fallback: unlike a missing scene service, there is no second way to close the last metre onto a tag, and pretending otherwise would move the robot on a guess. Two details of the adapter's docking client differ from every other client it holds, both because it is the one that moves the base for minutes at a time: no transport retries (a re-issueddock_to_markerafter a dropped connection drives the robot a second time, from wherever the first attempt left it) and its own, much longer call budget (the shared 120 s would cancel a healthy approach, and cancelling stops the base). - So is the stream:
stream_start/stream_stopdelegate to the same service, which owns the GStreamer sender. Attaching:9230to the brain directly instead is the wrong fix and worth stating once — vision and the adapter both advertiseget_frame,list_cameras,capture_sceneandpixel_to_3d, and MCP tool names are bare by default, so four names would collide and break planner routing. Everything the brain needs from vision comes through the seam.
If your robot cannot honestly implement something, do what the adapter does: leave the
tool out (or return {ok:false, reason:"unsupported"}) and say so in prompt://system.
An optimistic stub is worse than a missing tool — the planner will build plans on it.
3. Fully utilizing the brain — optional capabilities¶
Each is independent; add the ones you want.
a) Named navigation + teaching places ("move to the kitchen table")¶
This is how the brain reaches somewhere not currently in view — the core "navigate to the named place first, then perceive + act" rule depends on it. Implement four tools that persist base poses (map frame) to disk:
| Tool | Does |
|---|---|
save_waypoint(name) |
record the robot's current base pose under name (teach a place by standing there) → {ok, name, x, y, yaw_deg}. This is the affordance operators actually use — nobody knows the map coordinates — so it needs a real pose read; on the R1 it is get_pose (§2a) over nav2's get_robot_pose. Without one, the brain's Use current position button has nothing to call and places can only be typed in. |
navigate_to_named(name, allow_stale) |
see below — allow_stale exists because of the frame problem |
list_waypoints() |
the places the robot knows → {ok, waypoints:{…}}; unknown name to navigate_to_named → {ok:false, known:[…]} |
list_waypoints() → staleness |
each place carries stale, plus the current session and a count — see below |
Flow: seed places once (save_waypoint*), and the brain injects their names into
planning, so "take the coke to the other table" becomes
navigate_to_named("other table") → perceive → pick → navigate_to_named(dest)
→ place — instead of guessing coordinates or assuming a place is already visible.
Operators can teach new places by name mid-session. Persist waypoints across
restarts (a JSON file is enough); keep names lowercase-insensitive for matching.
Persisting the coordinates is not enough — persist, or invalidate, the FRAME (BIN-213)
A waypoint is a pose in your localization's frame, and on most stacks that frame is rebuilt
every time the robot starts. On the R1: in sim nav2.launch.py's slam argument defaults to
true, so slam_toolbox maps online from /scan and nothing ever loads or saves a map; on the
real robot Fast-LIO2 publishes map→odom as an identity static TF (no AMCL, no
map_server). Either way the origin is wherever the robot happened to be standing at boot.
So a persisted waypoint file plus a rebuilt frame is worse than no waypoints: "kitchen table" now names a different physical place, and the robot drives there confidently. It reads as a nav bug, which sends whoever debugs it into the wrong layer entirely.
Two acceptable answers, and you must pick one:
- Anchor the frame. Save the map and localize into it on the next boot (
slam:=false map:=<path>+ AMCL, or a relocalization node publishing a realmap→odomcorrection). Then a waypoint means the same place twice and nothing more is needed. - Or admit the frame moved. Stamp each saved place with a session id that changes when
localization restarts, return
stalefromlist_waypoints(plus the currentsessionand a stale count, so a UI can warn before the operator picks one), and refusenavigate_to_namedfor a stale place — with the remedy in the payload and an explicitallow_staleescape for a caller who has independently established the frame survived.
Refusing is the safe direction: the alternative is a successful navigation to the wrong room. A place saved before you had stamping has no id, and the honest reading is stale — it is not known to be current, and guessing optimistically is the whole failure.
The R1 does the second today (R1_NAV_SESSION, minted per r1ctl up, which starts nav2 and
the adapter together so the id changes exactly when the frame does). The first is the real
fix and is still open.
Persisting the file is not persisting the place
A waypoint is only meaningful in the frame it was recorded in. If your localization
rebuilds that frame at every bring-up — online SLAM with no saved map, or a LIO whose
origin is the boot pose — then a stored coordinate points somewhere else after a
restart, and the robot will drive there confidently. That is the R1's situation today
(BIN-213): the sim runs slam_toolbox in mapping mode and the real robot runs Fast-LIO
with an identity map→odom, so places survive the restart but their meaning does not.
Either anchor the frame (a saved map + relocalization) or tell the operator which
session a place came from — do not let a stale coordinate look like a known place.
b) The surveillance watchdog (live oversight + auto-replan)¶
You do not implement the watchdog. You feed it (BIN-328). Its two halves both run in the brain:
| Half | What it needs from your robot |
|---|---|
| the visual judge — a VLM in the brain, over the camera frame | get_frame (free), plus list_cameras so it can pick a wrist camera when the question is about the gripper |
| the deterministic body checks — joint limits, stale joint states, frozen-while-moving | the world snapshot's body block, i.e. joint_state / ee_pose / gripper_state / get_pose |
| fault signals it cannot derive — e-stop, driver/motor faults, self-collision | fault_state, which only your adapter can bridge. The brain cannot infer a motor fault from joint angles, so a robot that does not report one leaves the watchdog with a real blind spot — an honest one, since fault_state says which signals are unreadable and why |
Optionally you may also expose a robot-property monitor, polled only when there is no world service to read a body snapshot from:
It has to be able to return ok: false. The R1's returned ok: true unconditionally for
months — a hard-coded clean bill of health from the one component whose job is to notice a broken
robot — which is the direct reason nobody ever caught it parked in an absurd posture (BIN-286
work item 4). Two things keep the fix honest, and they pull in opposite directions:
- Fail on a fault the robot actually reported, naming it in
issue. Prefer signals that latch (a driver error code) over ones that flicker: a watchdog that aborts a plan on a transient is worse than no watchdog. - Do not fail because a signal is missing. When faults are unreadable, stay
ok: trueand say so inreasoning. A monitor that trips because the driver went quiet is unusable, and one that reports health it cannot see is worse — so say which of the two you are in.
While a plan runs the brain polls every interval_s and streams findings to the Debug panel.
Three consecutive not-ok visual verdicts (vlm_strikes), or a hard body fault, abort the plan
and make the brain replan.
A missing half is an error, not a quieter watchdog
If the brain cannot see — no world service attached, no camera frame arriving, or a planner
model that cannot read images — that is reported as a stack fault, logged at ERROR, shown
in the findings feed, and after three such ticks it stops the plan. There is no degraded
mode to fall back to, because a mission running under a watchdog that cannot see is a mission
nobody is watching. Make sure get_frame works on your robot before arming the observer.
c) Ship your robot's prompt (this is how robot guidance reaches the brain)¶
The brain image is robot-agnostic — it bakes no robot prompt. Your robot's
identity, tool descriptions, plan templates, and manners reach the planner by
being advertised from an MCP resource prompt://system:
@mcp.resource("prompt://system")
def system_prompt() -> str:
return "Robot: <arms/grippers, reach, constraints>. <How to use your tools>. <Plan templates>."
r1-abstraction serves its whole planner prompt this way — the _PROMPT
string returned from prompt://system in r1_abstraction/server.py, which tells the
planner to call the grasp skill rather than composing the primitives itself.
The other thing tool schemas can't say is your joint-space vocabulary, and leaving it out
has a specific failure mode: a planner asked to gesture or to park an arm will improvise from
Cartesian poses, because IK is the only thing it was told about. List the goto_named groups
and their named postures, mark which ones move the torso, and give the joint names for
plan_execute(group, joints). The R1's prompt does this since BIN-196 —
the rule.
d) Learned processes / skills (repeatable how-tos)¶
Run a process-store MCP server named memory (agent.memory_server;
reference: robot-mcp-memory) exposing find_processes(query) / save_process(…).
The brain retrieves matching processes into the planner at mission start and can
save a taught procedure. Leave it out (agent.memory_server="") and the brain
still works, just without process memory.
e) Recovery lessons (learning from failures)¶
Nothing to implement — the brain distills lessons from failed→fixed→succeeded runs (and from your mid-mission corrections) and injects them into future planning via the shared store. You only need consistent tool names/returns so a lesson learned once applies again.
f) Async events — pushing something the brain didn't ask for (advanced)¶
For the robot to proactively tell the brain something mid-mission, POST to the brain's
ingress POST http://<brain-host>:<port>/api/agent/events, signed with the HMAC secret the
brain knows as RVC_EVENT_SECRET and your manifest entry carries as event_secret. This is
the same ingress the Async Task Contract uses for task_done/task_failed, so a server that
already dispatches long tasks has the plumbing.
Optional, and nothing attached today pushes a TaskEvent this way: the reference
implementation was mcp-perception-buffer's watch_event, retired in BIN-306. The
watchdog in (b) covers most oversight needs, and it pulls — the cheaper default,
since a push contract needs a secret on both sides and a reachable brain from the
robot's network.
A different push ingress is live and used, though: (i) below reports spend, not
a TaskEvent, over its own route with its own fleet-wide secret — no manifest
entry needed, unlike this one. It's the fallback of the two channels (i)
describes, not the default: reach for it only for spend a tool result can never
carry, since the manifest-free return-field channel needs no secret at all.
g) Clarification (ask-back) — free¶
When a request is ambiguous the brain asks the operator with options; nothing to implement — it uses the tools + prompt you already provide to decide when to ask.
h) Visual Command GUI (direct manipulation)¶
Powers the operator's segmented-camera view (click an object to pick, tap a spot to
place, draw an arrow to arrange, jog the base). Expose the scene trio from §2b —
get_frame, capture_scene, pixel_to_3d — plus grasp_from_pixel(u, v, side?) for
click-to-grasp.
The brain streams get_frame continuously for a live view and only calls
capture_scene on a key step or an explicit Re-segment — so segmentation can be
as slow/expensive as it needs to be, and a paid segmenter is billed only when asked
for. Reference implementation: binabik-r1-vision (:9230) — a robot-side ROS 2 node
built on the robot-agnostic binabik-scene-perception core — which r1-abstraction
delegates to (§2f).
Reusing the scene core: what it gives you, and two surprises in its ports
binabik-scene-perception is the quickest way to implement this trio — it is ROS-free and
takes a caller-supplied deprojection function, so your node supplies the frames and the
geometry while the core does grounding → segmentation → the object list. Two things to know
before you implement its ports:
- It reports
segmenterandsegmenter_cost_class(metered/fixed), not the tier. If you resolve a fallback chain the way the R1 does,segmenter_tier/segmenter_detail/segmenter_error/skipped[]are yours to add — the core is handed one already-chosen segmenter and never sees the alternatives, which is why §2b lists them as fields an implementation may add, never as contract. Grounderhas three abstract methods and one live caller.point()is the onecapture_scenecalls.ground()(reasoned answer + localization) andquery()(a free-form semantic side-channel) are port surface with no caller anywhere in the stack — they served the archivedrobot-mcp-perception's reason/describe modes — yetmypystill requires them of your adapter. Implement them honestly or raise; do not stub them into something that looks like it works, which is the trap of a port wider than its use. Whether they stay is BIN-186.
Making the live view a real stream¶
The get_frame loop above is a ~1.4 fps slideshow — a whole independent JPEG every 0.7 s,
base64'd (+33 %) and relayed through the brain's event loop. It is the floor, and it stays: it is
what a robot without a stream sender, a simulator, and any viewer whose negotiation fails all
use. stream_start / stream_stop (§2b, both ○ optional) put a real H.264 stream over it.
The brain is a broker, not a peer, and this is the one place in the contract where that matters. It relays two opaque strings per viewing session and carries no media at all:
browser ──visual_stream_offer {sdp}──▶ brain ──stream_start(offer_sdp, viewer_id, viewer_ip)──▶ your adapter
browser ◀──visual_stream_answer {sdp}── brain ◀────────────── {ok, sdp} ───────────────────────┘
└────────────────────── H.264 / SRTP, never through the brain ──────────────────────────────▶
What that requires of you:
- One complete SDP exchange, no trickle ICE. Gather every candidate, then answer. The brain has no channel to relay candidates on and will not grow one — there is exactly one round trip by design.
iceServers: []on both sides.host↔hostUDP over the tailnet: no TURN, no STUN. A STUN server would only discover an address the tailnet already routes to. Note this does not mean the path is direct — Tailscale relays through DERP when it cannot NAT-traverse, and your sender cannot tell. Assume a relayed, lossy path: negotiate plainnackand an RTX payload type so a lost packet is retransmitted, or every loss costs the viewer everything up to your next IDR (BIN-576). Advertising onlynack pliis what this contract used to get.- Never raise. Answer
{ok: false, message}when you cannot serve a stream — the sender is not running, the camera has published no frame yet, the encoder is unavailable. The brain reports yourmessageto the operator verbatim and stays onget_frame, so a refusal is a supported outcome rather than an error path. An{ok: true}with nosdpis treated as a refusal too, since an empty answer would leave the browser waiting forever. viewer_idis the brain's socket id, andstream_stopmust be idempotent — an unknown id answers{ok: true}. The brain calls it when a viewer leaves the view, when its socket drops, and when its decode watchdog gives up, and it may call it for a viewer that never negotiated.- Share one encode across viewers. Every viewer streams, not just the operator holding the
control lease, so a per-viewer encoder is the difference between a few percent of a core and
saturating one. A
teeoff a single encoder is what makes lifting the controller-only restriction affordable at all. -
Use
viewer_ip, and treat it as a hint. This is the counter-intuitive one, and it exists because the brain brokers the exchange: the address the offer arrived from is the brain's, not the viewer's. A browser also hides its own host candidates behind random<uuid>.localmDNS names unless the page holds media-capture permission, and an unresolvable.localname means an empty ICE check list — fatal on webrtcbin 1.20.3. So the brain supplies the viewer's address and you append a candidate at it rather than replacing anything.""(the brain could not establish it) is a legal argument, not an error.An absent hint is better than a wrong one
Do not assume a wrong value merely wastes a candidate pair. When the browser has obfuscated its host candidates, the appended candidate is the only pair there is, so a wrong address costs the entire stream. Measured on 2026-09-10: a
.local-only offer plus an appended candidate at the brain's Docker bridge gateway produced an ICE check list with nothing reachable in it at all.Where it comes from — and never require it
Only a proxy that states the address in a header can supply this. For the central host that means
tailscale servepopulatingX-Forwarded-For, which is verified end to end (a laptop's own tailnet address, not the proxy's).What does not work is reading the connection's own source address: Docker runs
docker-proxyper published port by default, so every connection reaches the brain as the bridge gateway. Measured three ways — through an SSH tunnel, straight over the tailnet from a laptop, and from a second host entirely — all172.17.0.1. So a brain published on a plain port with no Serve in front of it cannot learn any viewer's address, and sends"".The stream must therefore work for the controller with
viewer_ipabsent, because that browser publishes real host candidates anyway; the argument is what rescues the spectator case, and only where the ingress supplies it.
A connected peer is not a streaming peer. Do not report success from ICE state — the
receiving end learned this the hard way, three times, on peers that reported perfect health
while carrying no media. The brain's client falls back to get_frame unless a frame actually
decodes, and calls stream_stop when it does not.
Reference implementation: binabik-r1-vision (STREAM_ENABLE, STREAM_BITRATE,
STREAM_ENCODER — see the R1 robot stack), delegated through
r1-abstraction for the reason §2f gives: attaching the vision service to the brain directly
would collide four bare tool names.
Manual jog / teleop — the robot provides the base-motion primitives; the brain only relays button presses and enables only the buttons for the tools you expose:
| Tool | Button |
|---|---|
turn_by(degrees) |
rotate in place (⟲ left = +, ⟳ right = −) |
drive_by(distance_m) |
drive straight (↑ forward = +, ↓ backward = −) |
strafe_by(distance_m) (optional; holonomic bases only) |
strafe (← left = +, → right = −) |
Keep each jog bounded (small step, speed cap, timeout) with a local watchdog — the brain calls these directly (not through the planner) for immediate manual control, and blocks them while a mission is running.
A base jog must measure its move¶
Report how far the base actually went, not that you started moving (BIN-195)
These three are the only primitives that move the base without a navigation goal, and every interesting caller repositions and then observes — the grasp survey, the recovery tier, click-to-pick after a jog. So the contract is not "start moving"; it is arrive, and say how far you got:
- Close the loop on your own odometry: keep commanding until the pose says you have arrived, within a stated tolerance.
- Return
achievedin the request's unit.okmeans arrived within tolerance, and nothing weaker. Onfalse,achievedis what the caller reasons about. - If you genuinely cannot measure (no odom), you may dead-reckon — but return
achieved: nulland say so inerror. Never echo the requested value back asachieved; that launders an estimate into a measurement. - Bound the loop: a budget, a stuck guard for a base held against an obstacle, and the safety stop must cut a jog already in flight.
The R1 shipped the open-loop version of this and it was invisible for weeks. /cmd_vel
carried exactly the requested 0.40 rad/s while the Gazebo base turned at 0.11–0.16, so
turn_by(45) moved 12–20° and answered success: true. Measured shortfalls: turn 3.0–4.5×,
drive 1.8–1.9×, strafe 2.1–2.2×, with ~40% run-to-run spread — no calibration constant
fixes that. Downstream, grasp-service's R1 pack had given up on sweeping altogether
(survey_tool="", "the R1 has no base-rotation primitive"), so a grasp only ever saw its
opening view and reported not_found for an object one turn away. One unmeasured primitive,
two layers of consequence, and no error anywhere.
Never report an outcome you did not check¶
A hardcoded success field is worse than a missing one (BIN-296)
The R1's place returned released: true — the literal constant — whether or not anything
let go. It shipped that way for months and nothing caught it, because a fabricated success
is indistinguishable from a real one at every level above.
Why this class of bug is worse than it sounds: the field is believed. The brain clears
its held-object state on released, so a place that silently failed left the robot carrying
something while the mission planned as though its hands were free — and the next grasp
reasoned about a gripper it thought was empty. The same shape as reading success (the
check ran) where you meant held (the object is there) — the reason verify_held's row
says to read held only.
So, for every outcome field in this contract:
- Measure it, or say you didn't.
released,held,reachable,achieved— each is a claim about the physical world, and a caller will act on it. If you cannot check, return the honest unknown (null, or averified: falseflag beside it) rather than the optimistic default. - A missing field is safer than a false one. A caller can handle "I don't know"; it cannot handle a confident lie.
- Distinguish "the check said no" from "the check did not run." They lead to different decisions — retry versus escalate — and collapsing them costs the caller the ability to choose.
- Do not spend a measurement you don't need. If the move itself failed, the outcome is already known; a VLM capture there buys nothing.
Who defines "held"¶
Describe your gripper; let the skill say what counts as success (BIN-295)
A held-check prompt mixes two things that belong on opposite sides of this seam, and it is worth pulling them apart deliberately because one of them cannot move and the other must.
- Your robot's morphology is yours, and cannot move up. "The white/gray two-fingered end-effector", "the two arms press against the left and right side faces and squeeze" — no two robots' held-checks read alike, and a skill has no way to write this for you.
- What counts as held is the skill's. "Lifted clear of the surface", "ignore every
other object on the table". That is the condition the whole grasp attempt is judged
against — it decides
missed, it drives the retry, and it is what a learned lesson is about. If it lives in your source, every robot re-invents it and no skill can tune it.
So accept a criteria argument, compose it as your preamble + the caller's criteria +
the answer format you can parse, and default to your own text when it is empty, so a
caller with no opinion gets exactly what you sent before.
Two clauses the R1 pays for every time they are missing, worth defaulting to:
- "clear of the surface" — IK, motion and a closed gripper all succeed on a miss. Contact is not a hold.
- "ignore every other
<object>" — the scene usually contains more of the same object, and a VLM asked "is there a can?" will happily answer about the one still on the table.
Parsing the answer is your job, and the precedence is load-bearing
If you ask a VLM, you must read a verdict out of prose. Strip any <think> block first,
then trust, in order: the answer format you demanded, a leading yes/no, and only then
the last bare yes/no — reasoning concludes at the end. Default to NOT held when
the reply says neither: a false "held" makes the caller believe it succeeded and drive
off with nothing, which is the worse failure.
On the R1 a leading-token check misread a reasoning model's chain-of-thought, declared a real hold not-held, the planner retried the pick, and the robot drove off still carrying the box.
Offer the choice; keep a sensible default¶
A good default is not the same as the right answer (BIN-295)
The R1 samples 36 headings around an object, checks each against its costmap, and picks the reachable one nearest the base. That is a good default. It is also the wrong answer for a two-arm squeeze, which presses an arm against each of two opposite faces — a stance 45° off a face has the arms straddling a corner with nothing to grip.
Neither layer can settle this alone: only the robot knows where it may stand, and only the skill knows what its grasp needs. So expose both:
- a query — "where could I stand around this object?" — returning the feasible set, not just your pick;
- an argument on the mover that accepts one of those answers back.
Keep your default for callers that express no preference, and make the two speak the same units so an answer can be passed straight back (the R1 uses odom-frame degrees, East/+X = 0, CCW positive, in both places).
The R1 had both halves already and the query was simply unreachable from above the seam, so the default was the only outcome — a reminder that "the robot decides" is sometimes just an accident of what got exposed.
Keep an error distinguishable from an empty answer
"There is nowhere to stand" and "I could not look" lead to opposite decisions, and a caller that cannot tell them apart abandons reachable objects. Report a costmap failure as a failure, and never let a caller mine a failed response for whatever data it still carries.
The caller owns the standoff¶
Two layers may hold a threshold; only one may hold the number (BIN-295)
Docking is decided twice, legitimately. The skill decides whether to drive at all — it holds the object, the strategy and the attempt budget, and re-docking to a stance the robot is already standing in is pure cost (66 s of it, measured, on one R1 pick). The robot decides how, including whether its own fine creep is still needed from where it is now.
Those two decisions are only consistent if they are about the same standoff. So the
caller sends stop_dist, and the robot treats it as authoritative rather than substituting
a default of its own.
The R1 shipped the other arrangement and it was invisible: the skill gated its skip on a
constant it never transmitted, and the robot answered against its own default. Both were
0.35 m, so it worked — by coincidence, with nothing enforcing it. Had they diverged, the
skill would have skipped docks it should have performed, and the symptom would have been an
out-of-reach object or a failed grasp. Never a message about a threshold.
The general rule, worth applying beyond this one primitive: when a decision is split across the seam, the parameter it turns on has to cross the seam too. A constant privately mirrored on both sides is not a shared decision; it is two decisions that currently agree.
Docking blocks on purpose, and cancelling it must stop the base¶
An approach that outlives its caller is a robot driving with nobody watching (KOE-35)
dock_to_marker and undock are the two tools in this contract that move the base for
minutes, under closed-loop control, with no human hand on it. That makes the obvious
implementation — hand the approach to a background task, return a handle, let the caller
poll — the wrong one, and it is worth saying why, because the Async Task Contract exists
precisely so a body can outlive its request.
Here that property is the hazard. A blocking tool inherits cancellation from the transport
for free: the caller dies, the call is cancelled, the servo's finally zeroes the base
within one control period. A detached task keeps driving and needs a bespoke watchdog to
undo its own design.
So three requirements, and they are requirements rather than advice:
- Every exit path ends with a zero velocity command, including cancellation — which
means never catching
CancelledError, at any layer the call passes through. An adapter that forwards these tools must let cancellation propagate; catching it to return a tidy refusal converts "the operator killed the client" into "the tool returned" while the wheels are still turning. ok: trueonly when the robot actually arrived. Not "the loop exited", not "the budget was not exceeded". Report the residual error, and report it in the units the rest of this contract uses.- Carry a dead-man below the loop, not just a clean shutdown path. The base must stop when commands stop arriving, on its own timer, because the thing most likely to go wrong is the thing that was supposed to send the stop.
That last one is not hypothetical. The R1's servo shipped with a dead-man that 675 passing tests said was working; a live run found it could never fire, because the loop restated its last command between marker sightings and the pump read every restatement as fresh. With the marker feed cut mid-approach, the base drove 36 cm against a design that claimed 0.4 s. Every test had asked the decision-making half what it decided, rather than asking the pump what it had been told. Test this one on a robot, and watch what actually reaches the wheels.
Note also what cannot be protected this way: a SIGKILL of the service takes its dead-man
thread with it, and neither Gazebo's velocity controller nor the vendor APIs we have seen
offer a stop-if-not-spoken-to setting of their own. If your base has one, use it — that is a
guarantee nothing in software can replace.
A base jog must be collision-checked¶
A jog that cannot see an obstacle will drive into one (BIN-294)
A closed loop on odometry answers "have I travelled far enough". It does not answer
"is anything in the way" — those are different feedback signals, and having the first
makes it easy to believe you have the second. The R1 shipped exactly that: turn_by /
drive_by / strafe_by published a Twist to /cmd_vel, closed the loop on odom for
displacement, and drove into whatever was in front of them. Nav 2's collision_monitor
did not save them either — see the topology warning below, and note that a monitor which is
running is not the same as one that is watching: the R1's is configured with a 1 cm
stop polygon, which its own comment describes as "effectively a no-op pass-through".
Every base mover in the contract must be collision-checked, including the jogs. They are used by a jog pad a human drives, by the grasp survey, and by the recovery tier — the last two unattended.
- Prefer your navigation stack's own behaviours. They already reason about the local
costmap. On the R1 the fix was not new code but a call: nav 2's behaviour server was
running the whole time with
spin_robot/drive_on_heading/backup_robotin it, and the adapter used two of that server's fifteen tools. - Add a velocity-level gate too (nav 2's
collision_monitor, or your equivalent) — but check your topic topology before believing it protects anything. A velocity gate only gates what flows through it: it subscribes to one command topic and republishes to another, and a publisher that writes straight to the output topic bypasses it entirely. On the R1 that is exactly the arrangement — the monitor readscmd_vel_smoothedand writes/cmd_vel, which is the topic the base consumes and the topic the raw jogs publish to. So the gate could never have caught them, however it was configured. If you want a gate to cover a publisher, that publisher has to be moved upstream of it. - Do not silently fall back. If the checked move fails, report the failure. A caller that asked for a checked move and unknowingly received an unchecked one is worse off than one that got an error, because it will believe the robot is safe.
- Say which you gave. Return a flag —
collision_checked— on every base-motion result. A caller cannot tell the difference by watching the robot, and neither can a log reader. - An axis with no checked equivalent must say so. Nav 2's behaviours are spin and along-heading only, so a holonomic base's sideways move has none. Keep it working, mark it unchecked, and give the reason — do not quietly ship it as if it were checked.
- Keep an escape hatch, off by default. A robot with no navigation stack up still needs
to jog. Make that an explicit opt-out (
R1_UNCHECKED_BASE_JOG=1on the R1), read so that an unset, empty or misspelt value leaves the guard on.
A check is only as good as the costmap behind it
State what your obstacle sources actually see. The R1's costmap is fed by the chassis lidar alone — the head cameras are RGB-only — so table edges and overhangs are invisible to it (BIN-298), and a "checked" move is checked against a world that omits them. Write that down where operators will read it; a safety claim that is broader than the sensing behind it is worse than an honest gap.
This rule and the measurement rule are both live at once: the behaviours typically report whether they succeeded, not how far they went, so the seam usually has to measure the displacement itself from a pose read before and after. Project a drive onto the heading it set out on (a path that curves around an obstacle should not count sideways drift as progress) and wrap a turn to ±180°.
A named motion moves what it names¶
Extra degrees of freedom are opt-in, never recruited silently (BIN-196)
move_arm moves an arm. If your robot has a torso, a waist, a lift or a base that
could help the gripper reach a pose, redundancy resolution must not spend them unless the
caller asked. Expose the choice as a flag — the R1 uses with_torso — and default it
off:
- A motion tool's name is a promise about which links move. Breaking it is invisible: the
IK solves, the trajectory executes, and the tool honestly reports
ok. - Prefer failing to contorting. An arm-only target that IK can't reach should return
IK found no solution, so the caller decides whether the reach was the point. That is strictly better than a success the caller didn't ask for. - Redundant chains also need a posture cost or a bias toward the current pose, or the same requested pose resolves differently on each call and repeated motion drifts instead of repeating.
The R1 shipped the opposite default and it took a user noticing. move_arm planned on the
11-DOF {side}_arm_with_torso group because the adapter dropped the parameter entirely, so
"wave the arm" swung the waist (torso_joint4, ±174° of yaw) to −62.5° and then −75.4°,
leaning 32° → 44°, while the base never moved. Identical requested poses produced different
postures, so it crept round rather than oscillating. To the user the whole robot turned; the
mission reported "waved the right arm in place".
Advertise your joint-space vocabulary in prompt://system (§3c)
A planner asked to gesture, rest an arm, or move something out of the way will reach for whatever it knows about. If all it knows is Cartesian IK, it will compose gestures out of end-effector poses — which is how the above happened; the trace's own reasoning read "I'm not certain about the exact joint names."
So name them: the groups goto_named accepts, each group's named postures, which of them
move the torso, and the joint names for plan_execute(group, joints). Say which single
joint yaws the body, if one does. This is a few lines of prompt that removes a whole class of
improvisation, and it costs nothing at runtime.
Use the canonical names, or your movers won't invalidate the overlay (BIN-158)
The masks are captured at one robot pose, so the brain drops the overlay — and refuses
click-to-pick against it — the moment a scene-disturbing tool runs, on both dispatch
paths (manual jog and every mission-plan call). It decides that from the tool name:
every canonical mover of §2a (navigate_to*, approach, turn_by, drive_by,
strafe_by, go_to_stance, goto_named, move_arm, plan_execute, tilt_for_depth,
pick, place) plus the L3 grasp.
A name it doesn't know would fail open — a stale overlay left on screen, still
clickable, over objects that have moved — so there is a fallback: any tool whose name
carries a motion stem (navigate, approach, drive, turn, strafe, stance,
reposition, dock) counts as motion by default. Two consequences for an integrator:
- Name a mover after the contract (or at least with a motion stem) and you inherit
the protection.
bin158_go_fastgets none. - A read-only tool must not read like motion. A query whose name carries a stem
(
where_to_stand,get_feasible_approach_directions— both already excused) would otherwise drop the overlay for nothing, costing a re-capture. Name queriesget_*/list_*/locate_*.
BIN-158 shipped because this list still named tools from the retired :9206 capability
server and was missing the R1's own approach and grasp.
You get the observer/livestream view for free
Once get_frame + capture_scene work, the same Visual view has an observer
mode: top-bar toggles hide the operator controls and overlay the robot's live
reasoning + plan in translucent panels over the camera stream (the
marketing/broadcast shot). That overlay is entirely brain-side — it reads the
mission/reasoning streams — so there is nothing extra to implement for it.
i) Reporting spend — what a call cost (BIN-240)¶
If your server calls something that costs money — a hosted LLM, a paid vision API, a GPU billed by the second — the brain's cost ledger wants to know, so an operator's month-to-date total and budget actually cover the robot, not just the brain. Send tokens plus identity, never a price: there is no price table on your server, and sending one would let a stale rate silently out-vote the brain's own the day a provider changes it. The brain prices every report on arrival, from the same table it prices its own calls against.
Two channels, in order of preference:
- Ride a tool result. Add a
usagekey to whatever you already return — a list of usage records, one per billed call your tool made. No secret, no URL, no configuration: it's authenticated by being the answer to a call the brain already made, and the brain strips the key before anything downstream (the planner included) sees it. This is the channel to reach for by default. POST http://<brain-host>:<port>/api/agent/usage, signed like §3f's event push but with a different, fleet-wide secret:RVC_USAGE_SECRET, the same value on both sides, never the per-serverevent_secret. (That secret's manifest entry makes the brain emit your server'snameas a tool-name prefix — fine for a server that pushes events, but it would rename every one of your tools the moment you attached it just to report spend.) Use this for spend a result can never carry: a bounded recovery loop that hits its deadline and returns nothing still spent money on every step along the way, and that is exactly the runaway a cost ledger exists to make visible.
Each entry is the shared UsageRecord shape (robot_mcp_kit.usage if you're on the
kit): who called what, on which provider/model, how many tokens of each class (or
gpu_seconds for a time-billed call), and — if you resolve your credential the way
the reference R1 stack does, through its key store —
which one paid. Leave
cost_micro_usd unset and confidence as "unpriced"; the brain fills both in. A
robot_id you already know is trusted; leave it blank and the brain stamps its own.
The kit ships both channels ready to use: robot_mcp_kit.UsageCollector (the
return-field half — call .drain() and attach the result) and
robot_mcp_kit.UsageReporter (the push half, same shape as EventPoster).
Optional, and nothing is worse off for skipping it — a server that reports nothing simply shows no spend in the ledger, same as before this existed.
4. Checklist: integrating a robot¶
Everything above the abstraction line is written once; onboarding a robot is implementing the contract below it + writing config. Your robot is ready when each box is ticked.
Minimum — connect and drive it.
- Tailscale up; MCP SSE servers bound
0.0.0.0at/sse, DNS-rebinding guard relaxed, bare tool names (§1). - Base:
navigate_to_named+list_waypoints—{ok, …}, base pose inmap. - Manipulation:
pick(object-centric — the required grasp tool;heldauthoritative,approach/sideoverridable) andplace. - Perception:
locate_3d,grasp_pose(close-range geometry inodom),verify_held. - Safety:
stop(preemptive, out-of-band) +clear_stop. - Asked the admin for an instance — for a non-R1 robot, with its own
robots/<name>.yaml(Running a brain).
Conventions + wiring (§2d).
- Metres, degrees (CCW-positive), an explicit
frame_idon every pose/point; sides"left"/"right". - Every argument your tools accept is declared in the schema, and an undeclared one
arriving is logged, not swallowed —
warn_on_undeclared_arguments(mcp)in a Python server (the rule). Silence here has produced three wrong answers that looked right — and the guard is tested over the wire, with a mutation showing it go red, because one tested only in-process is inert in production. - One authoritative flag per return (
okon L4,successon L5 grounding), plus the booleans/values later steps read (held,side,released, coordinates) and a shortreasonon failure. - Motion tools block until done and emit progress;
stopis never queued behind them. - A
prompt://systemresource advertising the robot's identity, its named places, which primitives/approaches it exposes, and what it deliberately doesn't (§3c) — including its joint-space vocabulary: thegoto_namedgroups, their named postures, which ones move the torso, and the joint names. A planner that isn't told these composes gestures out of Cartesian IK (why). - Every motion tool moves only the links its name implies; extra DOF are opt-in flags defaulting off, and an out-of-reach target fails rather than contorting the robot (the rule).
Richer surface — what each addition unlocks.
- Stateless grasp primitives (
perceive,compute_grasp_joints,plan_execute,approach,verify_held+ the scene group) → an L3 skill's FSM runs as your tuned sequence, and recovery can re-drive one step (§2e). -
save_waypoint/list_waypoints/navigate_to_named→ teach named places (§3a), and say which bring-up each place belongs to (the frame warning below). -
get_frame+ the body reads (joint_state,ee_pose,gripper_state,get_pose) → the live watchdog + auto-replan (§3b). The judging happens in the brain; you supply the raw material, not a verdict. -
fault_state→ the fault signals the brain cannot derive from body reads (e-stop, motor/driver faults, self-collision). Every signal you cannot read is absent with a reason, neverfalse; a clean bill of health needs every source to have answered, while one found fault is reported on its own. Mutation-test each guard. - A
memoryprocess-store server → learned procedures (§3d). - Perception-event POST → proactive mid-mission events (§3f).
-
get_frame+capture_scene(+pixel_to_3d,grasp_from_pixel) → Visual Command, click-to-pick, and the observer view (§3h). - A
usagekey on a paid tool's result (orUsageReporterfor spend a result can never carry) → your calls show up in the brain's cost ledger (§3i). -
turn_by+drive_by(+strafe_byon a holonomic base) → the manual jog pad, and an L3 skill that can look around instead of only forward. Each must reportachieved(why) — a jog that only reportsokis worse than no jog, because a skill will sweep with it and then observe from a pose it never reached. -
go_to_stance,compute_ik,goto_named,get_pose,gripper_state→ better stance/reachability reporting and diagnostics. -
locate_marker/detect_markers+dock_to_marker/undock/save_dock/list_docks/dock_status→ parking precisely in front of a machine, which a waypoint cannot do: a waypoint is map-frame and the map is rebuilt every bring-up, while a dock is defined against a marker bolted to the machine and therefore survives a restart. Implement these if any step of your task has to land on a shaft, a tray, a charger — anywhere a robot-width of error is too much. Read the blocking rule first: the two motion tools must block, must stop the base when cancelled, and must carry a dead-man below the loop. Exercise all of that on the robot, not in tests — the one defect that mattered on the R1 was invisible to 675 of them.
Sign-off.
- Model the adapter on the reference implementation,
r1-abstraction(a pure MCP client that delegates to the robot's own servers) — §2f. - Every canonical tool smoke-tested directly against your server (call it over MCP, check the return shape) before involving the brain.
- Live-run a full mission through the brain — including one
grasp— and pin your robot's stack per target (sim ref vs real ref).
The brain's controller/watcher, ask-back, recovery-learning, and multi-instance config are all brain-side — you get them for free once connected.
Where to go next¶
- The R1 robot-side stack, bring-up and smoke tests end to end: R1 robot stack.
- The grasp skill's design — the FSM, strategy selection, recovery, learn-back: grasp-service spec.
- The whole-stack picture — layers, where things run, honest state: Architecture.
- Running the brain that drives your robot — every
brainctlcommand and flag: Running a brain. - Driving it — the mental model, the chat + Debug panel, the Visual Command / observer view: Using the brain.
- Standing the whole thing up yourself, sim included: Set up your own robot + sim.