Skip to content
AuthorPascal DateJuly 29, 2026 Rev1.5

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 adapterr1-abstraction (:9220) — the R1's implementation (it also serves its prompt://system). §2f records exactly what it exposes and where it deviates.
  • an L3 skillgrasp-service (:9210, grasp(object)); the copy-me scaffold is skill-service-template.
  • the scene/vision servicebinabik-r1-vision (:9230) — capture_scene / get_frame / pixel_to_3d, and the WebRTC stream sender behind stream_start / stream_stop (STREAM_ENABLE, on by default wherever the vision service runs).
  • processes/skills memoryrobot-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 to uv sync. In CI, consumer repos authenticate with a read-only deploy key (repo secret RMK_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):

tailscale up
tailscale status | head -1   # note YOUR machine's tailnet name

2. Start your robot + its MCP servers, then sanity-check they listen:

ss -ltn | grep -E ':(9220|9210)'   # each should show 0.0.0.0:...

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/clear calls);
  • 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, not motion.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: pick attempts 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)

  1. Tailscale on the robot's machine: tailscale up.
  2. Run your MCP servers as SSE, bound to 0.0.0.0 so the remote brain reaches them over the tailnet. With FastMCP:
    mcp.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")
    
    Binding 127.0.0.1 is the single most common mistake: the server looks healthy locally and is unreachable from the brain.
  3. 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-vision does (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.

  • open means 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.
  • closed does not imply empty. A thin object leaves the fingers nearly shut, so consumers treat closed as 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 state rather than bucketing it.
  • holding is 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 a holding you 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, while state — 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 approachapproach_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 of add_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 a finally path that cannot know what was added. attached selects the stored id — attach_object files the body under picked_<object_id>, a plain world object under its own — and getting that wrong fails silently, because the no-op still reports success.
  • Make detach_object release and remove, or say loudly that it does not. The R1 adapter takes the second route: its detach_object docstring now states that the body lands in the world and keeps colliding, and names the two counterparts. Internally galaxea_agent always knew the difference — _td_cleanup_scene pairs detach_collision_object with remove_collision_object_from_scene and 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_box on 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: false with a reason naming what the caller should use instead. This is what the R1 does (BIN-555): r1-abstraction's get_frame forwards the flag to binabik-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 at capture_scene for 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.

Diagnosticssim_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 in approach/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 place should echo the side it 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 side on 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 later place(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 in odom (drift-free); every pose/point carries an explicit frame_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's navigate_to_pose takes yaw in radians, and handing it a yaw_deg gets you a robot facing east: the unknown key is dropped in silence and yaw falls back to its default (the rule below). This paragraph used to say the opposite — "MCP tool schemas are additionalProperties: 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 answers get_robot_pose as {position:{…}, orientation:{yaw, yaw_degrees}}) into the flat contract shape. Both of those mismatches shipped in r1-abstraction and 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, never l/r or 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 emits additionalProperties: false only for extra="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 — camera accepted and ignored (BIN-302), carry dropped so a two-hand box attached as gripper-carry (BIN-504), annotated dropped 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 bound self.call_tool it 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 called mcp.call_tool() directly kept passing while 11 wire tests failed. So call through the registered CallToolRequest handler — 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 _name trips InvalidSignature at 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 to info passes 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 original CallToolRequest handler 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 undone
    

    That 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 calledassert 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 misspelt annotatd yields the free plain frame; a misspelt tigger falls 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 Schema examples (falling back to default) as its grey placeholder, and object: str becomes object: "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 enum unless the set really is closed, because an enum refuses every other value. grasp's three approaches are examples for 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 ordinary enum — 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
    waypoints your list_waypoints a 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 — ok on the L4 motion/IO tools, success on the L5 grounding tools (locate_3d, grasp_pose, where_to_stand). Accept and, where cheap, return both; callers in this stack read ok or success. For a grasp, held — not ok — is the truth. - Errors: on failure return the flag as false plus a short reason (a stable token like not_found, unreachable, timeout) or a human message. 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 as false with a reason. What you must never return is the plausible-looking value: joints: [] from a robot that cannot read its joints, at_limit: false with no URDF, ok: true from 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 implement joint_state()"), an ok: false reads 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's progress as seconds since this invocation began, monotonic, restarting at 0 for each new call — the brain surfaces it verbatim as elapsed_s and 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 tell 3 meaning "step 3" from 3 meaning three seconds. Today this is a convention the brain validates rather than a contract it can check; BIN-492 tracks stating it as one. - stop is out-of-band: it must be serviced preemptively, not queued behind the motion it is meant to cancel. A robot whose stop waits 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 are stop, 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 in BY_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 (stoprap_robot.stop), and nothing else. Most absences degrade quietly-but-visibly (one notice, or a disabled control that says why); stop is 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, bound 0.0.0.0 on 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_server returns "Successfully spun robot by -0.79 radians", and for months every successful base move, waypoint navigation and stop-cancel reached the brain as Error executing tool turn_by: tool 'spin_robot' did not return JSON while 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 with call_text at 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:

  1. Stateless. Each primitive takes explicit inputs and returns data; nothing about this object is remembered between calls. perceive returns the pose/dims/face normal, and the skill threads them into compute_grasp_joints, approach, plan_execute. (The retired R1 design stored the object inside the robot at a detect_* 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.
  2. Self-verifying and honest. A primitive checks its own effect and says so; the skill trusts held from pick/verify_held and nothing else. A failure returns false + a reason — it does not retry internally on the skill's behalf, and it does not report success optimistically.
  3. 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.
  4. Budgeted. grasp-service allows 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_box is the physical release (widen the arms, detach and remove, clear the octomap, home) and remove_object_from_planning_scene is the idempotent second half of a detach. grasp-service now 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_state bridges the R1 Pro driver's six hdas_msg/FeedbackStatus topics through galaxea's ROS introspection server, using two read-only tools and re-exposing none of them. On the R1 fault (driver motor faults) is readable and e_stop / self_collision are not — both are absent with the reason, per the rule above. On the Gazebo sim those topics do not exist at all, so fault is 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 preemptive stop.
  • 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_pose is implemented, over nav2's get_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_held deliberately returns galaxea's VLM check as {held} and drops its success flag, which once produced a false positive on a missed grasp.
  • Scene capture is delegated: capture_scene / get_frame / pixel_to_3d go to binabik-r1-vision (:9230, R1_VISION_URL), and when that service is down the adapter falls back to galaxea look — the frame comes back with an empty objects list rather than an error. Vision is opt-in (VISION_ENABLE=1 r1ctl up) because every capture_scene runs a paid Gemini grounding call.
  • So is docking (KOE-36): the five docking tools go to binabik-docking (:9250, R1_DOCKING_URL), and locate_marker / detect_markers to 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 on ok. 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-issued dock_to_marker after 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_stop delegate to the same service, which owns the GStreamer sender. Attaching :9230 to the brain directly instead is the wrong fix and worth stating once — vision and the adapter both advertise get_frame, list_cameras, capture_scene and pixel_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 → picknavigate_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 real map→odom correction). 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 stale from list_waypoints (plus the current session and a stale count, so a UI can warn before the operator picks one), and refuse navigate_to_named for a stale place — with the remedy in the payload and an explicit allow_stale escape 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:

robot_state() -> {ok: bool, issue: str, reasoning: str}

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: true and say so in reasoning. 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>."
The brain fetches it on connect and composes it after its universal base (you refine the shared rules, you don't replace them). So the prompt travels with the tools: change it → restart your server → only your robot picks it up, no brain rebuild. Put as much or as little as you need here (a one-liner, or your full plan templates), and use it to declare the things the tool schemas can't say: which primitives you decompose, which grasp approaches you support, what your base can do. Reference: 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 segmenter and segmenter_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.
  • Grounder has three abstract methods and one live caller. point() is the one capture_scene calls. ground() (reasoned answer + localization) and query() (a free-form semantic side-channel) are port surface with no caller anywhere in the stack — they served the archived robot-mcp-perception's reason/describe modes — yet mypy still 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. hosthost UDP 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 plain nack and 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 only nack pli is 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 your message to the operator verbatim and stays on get_frame, so a refusal is a supported outcome rather than an error path. An {ok: true} with no sdp is treated as a refusal too, since an empty answer would leave the browser waiting forever.
  • viewer_id is the brain's socket id, and stream_stop must 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 tee off 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>.local mDNS names unless the page holds media-capture permission, and an unresolvable .local name 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 serve populating X-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-proxy per 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 — all 172.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_ip absent, 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 achieved in the request's unit. ok means arrived within tolerance, and nothing weaker. On false, achieved is what the caller reasons about.
  • If you genuinely cannot measure (no odom), you may dead-reckon — but return achieved: null and say so in error. Never echo the requested value back as achieved; 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 a verified: false flag 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: true only 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_robot in 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 reads cmd_vel_smoothed and 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=1 on 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_fast gets 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 queries get_* / 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 usage key 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-server event_secret. (That secret's manifest entry makes the brain emit your server's name as 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.0 at /sse, DNS-rebinding guard relaxed, bare tool names (§1).
  • Base: navigate_to_named + list_waypoints{ok, …}, base pose in map.
  • Manipulation: pick (object-centric — the required grasp tool; held authoritative, approach/side overridable) and place.
  • Perception: locate_3d, grasp_pose (close-range geometry in odom), 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_id on 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 (ok on L4, success on L5 grounding), plus the booleans/values later steps read (held, side, released, coordinates) and a short reason on failure.
  • Motion tools block until done and emit progress; stop is never queued behind them.
  • A prompt://system resource 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: the goto_named groups, 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, never false; 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 memory process-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 usage key on a paid tool's result (or UsageReporter for spend a result can never carry) → your calls show up in the brain's cost ledger (§3i).
  • turn_by + drive_by (+ strafe_by on a holonomic base) → the manual jog pad, and an L3 skill that can look around instead of only forward. Each must report achieved (why) — a jog that only reports ok is 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 brainctl command 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.