Skip to content
AuthorPascal DateAugust 10, 2026 Rev2.6

The R1 robot-side stack — r1ctl reference & runbook

Everything that runs on the robot host: the r1ctl launcher and every knob it has, the ports / tmux windows / env vars of each server, the smoke tests that prove the MCP path, and the R1-specific troubleshooting + recovery procedures.

Mental model in one line. galaxea_agent's MCP servers expose the robot's own tools; binabik adds a thin adapter on top (so the robot speaks one standard contract) and a generic grasp skill on top of that. r1ctl brings up the sim, those galaxea servers, and both binabik layers — in two tmux sessions, from one r1.env.

This is a reference, not a first-time install guide. Standing the stack up from zero → Set up your own robot + sim. Anything brain-side (every brainctl command and flag, brain.env, central memory, HTTPS) → Running a brain — there is deliberately no brain command on this page. Driving the robot from the UI → Using the brain.

Audience. The engineer operating or debugging the robot host — in the lab that is rap-1 (user ros, home /home/ros, repos under ~/rap).


r1ctl — the subcommands

binabik-r1-host is the robot-side counterpart to the brain host's launcher. One script, one config file (r1.env next to it, overridable with R1_ENV), two tmux sessions.

Command What it does
r1ctl install Clone/pull galaxea_agent, r1-abstraction, grasp-service (+ binabik-r1-vision and binabik-docking only when VISION_ENABLE / DOCKING_ENABLE are already set — see the warning below), build their venvs with uv sync, run galaxea's install_mcp_local.sh, then clone galaxea_isaac_moveit on main and build the colcon workspace via install_sim_deps.sh. Also rewrites the org's HTTPS git base → SSH so uv can fetch the private robot-mcp-kit. A sim-build failure warns but does not abort; a galaxea install failure is fatal — why.
r1ctl env Create r1.env and galaxea_agent/.env from their committed examples only if absent — it never overwrites either — then write the BINABIK_SECRETS_FILE pointer and resolve/persist SAM3_SERVER_URL. It writes no credential (BIN-439); it used to prompt for GEMINI_API_KEY and mirror it into both files.
r1ctl provision Create this robot's read-only identity at the secret store, once per robot: a service account for it alone, one conditional secretAccessor binding on its own prefixes, the key, and secrets/$ROBOT_ID.env. Runs on an operator's Google auth and keeps nothing — details. Deliberately not a step in install or up.
r1ctl up (aliases start, reset, restart) The whole thing. Refuses an ambiguous session name first, installs only the components that are missing, kills the previous run and frees its ports, brings the sim up (or reuses a running one), preflights the python3 a pane will resolve, launches galaxea's MCP servers via its own start_mcp_local.sh, then the adapter + grasp (+ the opt-in services — bound to loopback if a tailscale serve proxy owns their port), prints status, and asserts the required MCP ports plus every opt-in you enabled. Doubles as the reset command — re-run it any time.
r1ctl down (alias stop) Kill the r1 session and free the binabik ports — 9220 9210 9240 9230 9250, derived from _binabik_ports so the list can't drift (BIN-177). Leaves the sim session (robot) running — deliberately, the sim is slow to boot. Does not free galaxea's ports; that session belongs to its own launcher, and up's reset clears it.
r1ctl status (alias ls) The session's windows plus every listening port, built from the port variables so it can't drift from what up manages. Also a == credentials == block: the store identity and backend in use, and — since BIN-582 — which source actually answered for each service that resolves one, read out of that service's own startup line rather than pointed at with a grep for the operator to run. It still never reports a key's value or claims a key works: which source answered is the service's report, whether the secret is good is the store's business.
r1ctl attach tmux attach -t =r1.
r1ctl logs [window] Last 60 lines of a window's pane; default window is grasp.

up's exit status tells the truth. It is non-zero when a required MCP port never opened — the failure mode that used to look like success (see the port assertion).

Restore r1.env BEFORE you install — and set the flags before you install

Two ordering traps, both of which cost a debugging cycle in the 2026-07-28 fleet rebuild:

  • A fresh clone has no r1.env, so install starts from r1.env.example — where VISION_ENABLE is commented out. The vision service then silently never launches. (_bootstrap_env never overwrites an existing r1.env; it is the re-clone that loses it.) Restore your backup first — see Rebuilding a robot.
  • install clones the optional repos only if their flag is set at install time. Setting VISION_ENABLE=1 afterwards self-heals — _installed() probes each service's venv (vision included since BIN-143), so the next up re-runs install and fetches what is missing. That auto-install also re-runs install_sim_deps.sh; since BIN-144 that is no longer destructive, but it is still a full dependency + build pass, so with a workspace you just repaired by hand prefer setting the flags and re-running install deliberately with SIM_ENABLE=0.

Log files

File What
/tmp/r1_galaxea_mcp.log galaxea's start_mcp_local.sh output — the one place that says why the galaxea servers didn't come up (GALAXEA_LOG).
/tmp/r1_sim.log the sim launcher's own output.
$R1_LOG_DIR/r1_<window>.log (default /tmp) — r1_adapter.log, r1_grasp.log, r1_world.log, r1_vision.log, r1_docking.log one per window, tee'd alongside the pane. r1ctl status reads these, to report which source answered each service's credential request (BIN-582), so the directory is one variable rather than a literal per call site: a reader hardcoding the path a writer chose reports ? for a service that is running fine.
$R1_CMD_DIR/r1_<window>.cmd.sh (default /tmp) not a log — the command itself. r1ctl writes every window's body to a file and sends one bash <file> line, so this survives the window dying: cat it for the post-mortem, edit and bash it to debug a launch by hand. Each body uses set -m + a kill-tree trap, which is also why every pane's current command is bash and the dead-window probe looks for child processes (BIN-227 #2, BIN-229).

The pieces, and how they talk

  brain (central, on the brain host)  ──MCP/SSE over Tailscale──┐
  binabik-world-state :9240 ────────────────────────────────────►  r1-abstraction :9220
   ("what is the world like" — body + scene; robot-agnostic,       (L4/L5 adapter)
    ROS-free, read by the brain, grasp and the watchdog)                ▲
  grasp-service :9210  ──CAP_URL = LOCATE_URL = 127.0.0.1:9220──────────┘
   (L3 skill, robot-agnostic)
                                                    │ MCP (streamable-HTTP, /mcp)
                                     ┌──────────────┴───────────────┐
                                     ▼                              ▼
  galaxea_agent: r1_manipulation :8004 · perception :8003     binabik-r1-vision :9230
  binabik-docking :9250 (opt-in)
                 nav2 :8001 · ros-mcp :8005 · rosbridge :9090  (opt-in scene service)
                        Gazebo sim (ROS 2 Jazzy) or the real R1
Piece Repo Port What it is
r1-abstraction — the adapter r1-abstraction 9220 A pure client of galaxea's servers. Presents the canonical contract — the stateless primitives the grasp skill drives (perceive, compute_grasp_joints, plan_execute, approach, verify_held, …) plus navigate_to_named / place / stop — forwarding each to one galaxea tool. Builds none of the robot's ROS code.
grasp-service — the skill grasp-service 9210 One high-level tool grasp(object): picks a chain (side / top / two-hand), runs it over the adapter's primitives, verifies the grip, and on a miss escalates to a scoped recovery LLM. Robot-agnostic — it talks to the adapter, never to galaxea.
binabik-world-state — the world snapshot binabik-world-state 9240 On by default (WORLD_ENABLE=0 skips it). The one "what is the world like" surface the brain's planner, the L3 skills and the surveillance watchdog all read: a derived posture sentence, joints with at_limit, both end-effector poses, base pose, grippers, and the cached scene inventory. Sits in front of the adapter and is ROS-free — it composes :9220's tools and subscribes to nothing, which is why it needs no ROS setup and no --system-site-packages venv. A plain read is free and never captures (~2 s on rap-1); a scene fill bills, so it needs refresh=true or an event trigger, and never a timer (BIN-347). Spec.
binabik-r1-vision — the scene service binabik-r1-vision 9230 Opt-in (VISION_ENABLE=1). Makes capture_scene return enumerated objects instead of a bare frame. Also publishes /marker/pose, which docking servos on. Details.
binabik-docking — the marker servo binabik-docking 9250 Opt-in (DOCKING_ENABLE=1). Closes the last metre onto an AprilTag, to ~2 cm — what a waypoint cannot do, since the map is rebuilt at every bring-up. Re-exported by the adapter on :9220.
galaxea_agent vendored on the robot 8004/8003/8001/8005 + 9090 The robot's own MCP servers + rosbridge. The grasp mechanics (IK, MoveIt, roll-search) live here.

robot-mcp-kit is never installed directly — both binabik servers pull it in as a dependency, and it provides the ToolClient the smoke tests use.

Transport map — two MCP transports are in play

Mixing these up is the most common bring-up error:

  • galaxea servers (r1_manipulation :8004, perception :8003, nav2 :8001, ros-mcp :8005) speak streamable-HTTP at the /mcp path — e.g. http://127.0.0.1:8004/mcp.
  • r1-abstraction (:9220), grasp-service (:9210) and binabik-r1-vision (:9230) speak SSE at the /sse path — e.g. http://127.0.0.1:9220/sse.

The kit's ToolClient auto-selects the transport from the URL suffix (…/mcp → streamable-HTTP; …/sse or a bare URL → SSE). A URL/path mismatch surfaces as "unhandled errors in a TaskGroup" on the first call.

The binabik servers bind 0.0.0.0 (tailnet-only in practice) — except when a tailscale serve proxy already owns the port, in which case up binds them to 127.0.0.1 on purpose and the proxy fronts the tailnet side (why). galaxea's bind loopback always, which is fine because only the adapter — on the same host — calls them.


What runs where — sessions, windows, ports

Two tmux sessions. r1ctl owns both; up brings the sim first because the galaxea MCP servers need move_group / nav2 / rosbridge to exist.

Session robot — the sim and its ROS stack

Created by the bundled sim/gazebo_sim.sh (SIM_LAUNCH_CMD's default).

Index Window What
0 monitor a shell; the session's DISPLAY / BINABIK_SECRETS_FILE / SAM3_SERVER_URL are setenv'd here before the rest are created
1 sim Gazebo Harmonic (gz sim) + the gz→ROS bridge
2 moveit move_group
3 nav2 Nav 2
4 perception the segmentation / perception nodes

up waits up to SIM_WAIT_S (150 s) for move_group to appear, then continues regardless — the MCP servers keep retrying.

r1ctl does manage the sim — SIM_ENABLE=1 is the default

On up: if the robot session already exists it is left alone (a slow sim persists across resets); otherwise you are asked Launch the Gazebo sim now? [y/N]. Off a TTY it launches only with SIM_AUTOLAUNCH=1, and is otherwise skipped — which then usually fails the port assert, because the galaxea servers have nothing to talk to.

r1ctl down does not touch the sim — that is on purpose, not an oversight. Stop it with the launcher, bash sim/gazebo_sim.sh --kill, which also reaps the gz sim server ros2 launch spawned. Not tmux kill-session -t =robot: that leaves the server running, so the next launch adds a second one on the same gz/DDS topics and one of them serves a stale world (BIN-227).

Override SIM_LAUNCH_CMD for other targets: the real robot (start_robot_nav_moveit.sh) or Isaac (start_isaac_sim.sh) — both scripts are on galaxea_isaac_moveit's main, which is the branch r1ctl checks out and the one this page assumes throughout. SIM_ENABLE=0 opts out entirely — then you bring the sim up yourself before r1ctl up.

Session r1 — the MCP servers

galaxea's start_mcp_local.sh creates its own windows at low indices; r1ctl uses explicit high indices so the two can never collide. (On macOS there is no start_mcp_local.shplatform/darwin.sh creates those four windows itself at indices 1–4, across three venvs whose dependency sets are mutually exclusive, and therefore owns their ports, which is why down frees them there and not on Linux.)

Index Window Port Enabled by
0…4 rosbridge, nav2-mcp, perception-mcp, r1-manip-mcp, ros-mcp 9090, 8001, 8003, 8004, 8005 always (galaxea's launcher)
20 adapter 9220 always
21 grasp 9210 always
22 vision 9230 VISION_ENABLE=1
27 docking 9250 DOCKING_ENABLE=1 (+ VISION_ENABLE=1 for the marker feed)
23 stream (none — see below) on by default with vision (STREAM_ENABLE follows VISION_ENABLE)
26 world 9240 always, unless WORLD_ENABLE=0

All of 9220, 9210 and 9240 must bind 0.0.0.0 — the brain reads them from another host. 9240 shipped on FastMCP's 127.0.0.1 default and was unreachable from the brain for the life of the service, while every check here passed (BIN-344); up now says which address each of the three bound.

The stream window is the WebRTC head-camera sender (BIN-262) and is the one window here with no MCP port of its own: it is a sibling process of vision, reached over loopback, and its externally visible surface is stream_start/stream_stop on :9230 (re-exported by the adapter on :9220). The media itself leaves on UDP to the operator's browser, negotiated per viewer, so there is nothing to bind and nothing for down to free.

Every service has its own index — tmux rejects a duplicate (index 22 in use), which used to abort up as soon as two optional services were on. Indices 24–25 are free: they belonged to the perception buffer and the two episodic windows, retired in BIN-306 (23 was the third and is now the stream sender's). They are left as a gap rather than renumbered, because an index is what an operator's muscle memory and every old log line refer to.

world is the one non-galaxea window that is on by default, and the reason is cost rather than importance: vision is gated because it spends money per capture (a Gemini call). The world state polls nothing and calls no external API — its only paid path is a scene capture, which happens on an event under a session budget — so there is no "did the operator ask for it" question and up asserts its port like the adapter's and grasp's. WORLD_ENABLE=0 removes it from that requirement as well as skipping it, because demanding a port from a service you turned off is how a launcher earns MCP_PORT_ASSERT=0.

~/rap is a separate 20 GB volume — and df -h / does not see it

Check the path, not the root filesystem

~/rap is not on the root filesystem. It is its own ext4 volume (/dev/vdb on rap-1, 20 GB), holding every robot repo and the ~5 GB binabik-r1-vision venv. df -h / tells you nothing about it — during the 2026-07-28 outage root showed 69 GB free at 86% while ~/rap sat at 0 bytes. Always check the path:

df -h ~/rap        # the number that matters

How that volume filled, and what it cost (BIN-175). The episodic recorder — ros2-memory, retired in BIN-306 — wrote ~2.9 MB per 10 s split, roughly 1 GB/hour. Unbounded, it wrote 8,336 segments (13 GB) in a single day, took the volume to 0 bytes, and so broke every robot-side git write: the first symptom was fatal: … .git/index.lock write error. Out of diskspace in an unrelated repo, five layers removed from the cause.

Nothing writes to that volume on a cadence today, so the acute risk is gone with the service. Two lessons are not, and they apply to the next thing that writes there:

  • A live port does not mean the work is happening. The recorder stopped the instant the disk filled and was still dead five hours later while its HTTP port answered normally. A service that produces something must report last-successful-write age, and that is the number to trust — never the port. (See checks that pass for the wrong reason.)
  • A producer bounds itself, on its own timer, with no client attached. Retention that only runs when someone asks is not retention. The free-space floor must be read from the volume being written, not /, which is the whole reason the outage was invisible.

The port assertion — required, optional, and "who asked"

After launching, up waits for the ports and makes the exit code tell the truth. There are four classes now, and the question the last one answers is not "is this required?" but who asked for it.

Variable Default On a port that never opened
MCP_PORTS 9090 8001 8003 8004 FAILED — the stack is NOT usable, non-zero exit. The adapter answers on :9220 either way, so the brain would see a robot with no capabilities behind it.
MCP_PORTS_OPTIONAL 8005 a single WARN: line. The stack is usable and the exit code is unchanged.
BINABIK_PORTS_REQUIRED 9220 9210 fatal and named — grasp is what the brain connects to and the adapter is what grasp calls (BIN-234). Shares MCP_PORT_ASSERT deliberately: one opt-out, not two.
derived from the opt-in flags (empty) fatal for a service you enabled and that died — vision (:9230) and, since KOE-36, docking (:9250). Empty when nobody opted in, so a default stack says nothing about either (BIN-243).
MCP_PORT_ASSERT 1 0 skips every assert above and the warning.
MCP_PORT_WAIT_S 45 raise it on a slow host.

All the lists are freed on up's reset and shown by status; move a port between the first two to change how loudly up reacts to it.

An opt-in you enabled and that died is a broken stack (BIN-243)

VISION_ENABLE=1 is a sentence someone typed. A stack that came up without it is not the stack that was asked for, so a dead :9230 is a FAILED and a non-zero exit, exactly like the adapter — while an absent opt-in stays silent, because a stack without vision is a perfectly good stack. That is the whole reason a missing :8005 only warns: nobody asked for it.

The banner names the service, the pane to read, and the flag to turn off if you have changed your mind. Two details:

  • The enabled opt-ins get a real wait (BINABIK_PORT_WAIT_S), not the one-shot probe the optional galaxea port gets. A fatal verdict must not race a service that was about to bind.
  • A port probe cannot see a service whose worker owns no port. The check therefore also runs a dead-window probe, so "the service is up" can never mean a query server answers while the half that does the work is dead. No opt-in has such a window today — the one that did, the episodic recorder, was retired in BIN-306 — but the mechanism stays: the next opt-in with a worker process needs it on day one, not after an outage.

:8005 (galaxea ros-mcp) is optional on purpose (BIN-138)

galaxea's generic ros_mcp_server (raw ROS pub/sub/service) has no client in the binabik stackr1-abstraction talks to r1_manipulation (:8004), perception (:8003), nav2 (:8001) and, when enabled, binabik-r1-vision (:9230). It used to be asserted like the rest, so every r1ctl up on rap-1 ended in FAILED — the stack is NOT usable while the stack was fine — the noise that obscured the real perception bug (BIN-137). A healthy stack must not report itself broken.

Why it was down at all: galaxea's launcher resolved tmux new-window -t "$SESSION" by prefix against window names, matched r1-manip-mcp, and aborted under set -e (create window failed: index 3 in use) before the fourth server got a window. It now exact-matches every target — but galaxea_agent runs a tuned tree on the robots, so the fix reaches rap-1 only via a surgical scp of start_mcp_local.sh.

BIN-141 carried the same treatment into galaxea's three Docker launchers — start_mcp.sh, start.sh, start_humble.sh — where the hazard was latent rather than live, and worse-shaped: start.sh/start_humble.sh own a window literally named nav2, so SESSION=nav2 collides with no prefix matching needed, and SESSION=ros is ambiguous against ros-sim/rosbridge/ros-mcp. All four launchers now exact-match (-t "=$SESSION") and address windows by the index tmux reports back from new-window -a -t "=$SESSION:{end}" -P -F '#{window_index}'. bash tests/test_launcher_tmux_targets.sh (renamed from test_start_mcp_local.sh) guards all four against a throwaway tmux server: the Docker ones run with a stub docker that reports the container already up — their normal reuse-a-live-container path — so no image or robot is needed, and a negative-control step re-breaks a copy of start_mcp.sh to prove the guard goes red. (start.sh/start_humble.sh need bash 4+ for their declare -A; on macOS bash 3.2 their behavioural half skips with a notice, source guards still applying.)

The session-name refusal (BIN-123)

up refuses to run while another session's name prefixes r1 (BIN-123)

tmux resolves -t r1 by prefix, not exact match, so running r1ctl up from a session called e.g. r1up used to destroy the real r1, create the adapter/grasp windows in the wrong session, and leave the galaxea servers unstarted — with no error, while the adapter kept answering on :9220. Every target in r1ctl + sim/gazebo_sim.sh is now exact-matched (-t "=r1"), and up refuses to start while another session's name begins with $SESSION — originally because galaxea's start_mcp_local.sh resolved its own -t $SESSION loosely and would "re-attach" to the stranger and start nothing. That launcher is exact-matched since BIN-138 (and the rest since BIN-141), so the refusal now stands as defence in depth: the robots run tuned galaxea_agent trees that only get the fix by surgical scp, so a given rap host may still be carrying the loose version. r1ctl's own message still names start_mcp_local.sh as the reason and is due a reword.

tmux rename-session -t r1up wrapper      # or pick a SESSION nothing prefixes
setsid nohup ./r1ctl up >/tmp/r1_up.log 2>&1 &   # or run it outside tmux

R1CTL_ALLOW_SESSION_PREFIX=1 overrides the refusal if you know what you're doing.

The python3 a pane resolves is not the one your shell resolves

up preflights it, because conda deactivate does not fix it

Every ROS Python node starts #!/usr/bin/env python3, so it runs whatever python3 the pane resolves — and a tmux pane starts a fresh interactive bash that re-sources ~/.bashrc. On a host whose rc auto-activates conda, that interpreter has no PyYAML, and rosbridge dies while the other four galaxea servers come up fine (they run their venv's interpreter by path):

[rosbridge_websocket-1] import yaml
[rosbridge_websocket-1] ModuleNotFoundError: No module named 'yaml'

A stack that reads 6/7 healthy, for a cause outside all seven. And the operator's instinct does not work: conda deactivate in the shell that runs r1ctl changes nothing, and neither does anything r1ctl exports — the pane re-reads the rc file either way. Measured on the host, a pane's /proc/<pid>/environ looked clean while its prompt read (base).

So up asks the question the way the pane will (bash -ic), before plat_launch_mcp, names the interpreter an interactive shell resolves, and gives the remedy that does work:

conda config --set auto_activate_base false     # then re-run ./r1ctl up

Fatal, with R1_ROS_PYTHON_CHECK=0 to opt out. The probe runs under setsid, stdin from /dev/null: bash -i enables job control and calls tcsetpgrp on the controlling terminal, which left r1ctl outside the foreground process group and stopped the entire run with SIGTTOU — with the sim launched and nothing else, a worse state than the failure the check exists to prevent. No setsid, no probe: it skips with a notice.

PLAT_ROS_PYTHON_FROM_SHELL is 1 on Linux and 0 on darwin — not because the hazard is Linux-specific, but because every macOS pane sources sim/mac_ros_env.sh and activates its own env, so the ambient shell's python never runs a node there. Probing it on a Mac would be a false alarm on a healthy machine.

A tailscale serve proxy and a 0.0.0.0 bind fight over the port

Proxy-then-server is EADDRINUSE, and --bg makes it every later bring-up (BIN-234)

A tailscale serve --bg --tcp <port> proxy binds the tailnet addresses, v4 and v6. A server that then binds the wildcard 0.0.0.0 over them dies instantly with error while attempting to bind on address ('0.0.0.0', 9220) — errno 48 on Darwin, 98 on Linux, both measured. Server first, then proxy works; the reverse does not, and because the proxies persist across reboots, every subsequent up gets the failing order.

up probes tailscale serve status --json per service and passes R1_ABSTRACTION_HOST=127.0.0.1 / GRASP_HOST=127.0.0.1 when a proxy owns the port, saying so as it launches. That is strictly more private — only tailscaled can reach the servers — and the brain connects through the proxy either way. Shared code, not a plat_* hook: the collision is kernel-level, so a darwin-only hook would have hidden a portable bug.

It fails open — no tailscale, no --json, an unresponsive daemon all mean "no proxy" and the wildcard as before, because binding loopback on a host that cannot answer would make the robot unreachable. R1_SERVE_PROXY_DETECT=0 turns it off. When the probe misses, up now fails loudly instead of printing up. over a table reading adapter 9220 down.

Two things to know when a port looks free but a bind fails:

ss -ltnp | grep 9220               # Linux: shows tailscaled's pid against the tailnet addresses
lsof -nP -iTCP:9220 -sTCP:LISTEN   # macOS: NOTHING — non-root lsof cannot see tailscaled's
                                   #   sandboxed system extension
netstat -van -p tcp | grep '\.9220 '   # macOS: this one names the holder (io.tailscale.ipn)

And never run r1ctl up as root on a host with the proxies up: the reset kills the holder of every port it manages, which here is root's tailscaled. As an ordinary user that kill is EPERM; as root it drops the tailnet.

Related, and the reason a dead adapter no longer reads as healthy: _port_open asks "is one of ours listening", not "is this port taken" — it requires a local address (loopback or wildcard). Every server r1ctl starts is loopback-reachable by construction, so a tailnet-only listener is tailscaled, not us. Under the old any-address test an adapter dead of errno 98 still printed adapter 9220 up and the failure banner never fired.

install is strict about galaxea

A failed galaxea install used to go into a parenthetical, with install done. three lines later — and up then met a missing :8001/:8003/:8004 with the cause long scrolled away. Those are the ports up calls required, so install stops there now. Two specific repairs came with it:

  • SIM_PKGS_DIR is passed to install_mcp_local.sh. That script installs the sim packages' requirements.txtopen3d, scipy, google-genai, the perception and manipulation servers' runtime deps — from $SIM_PKGS_DIR, whose own default is a path from the machine it was written on that exists on no r1ctl-managed host. Every fresh install printed SIM_PKGS_DIR (…) not found — skipping sim requirements. and quietly went without them. r1ctl knows where it cloned the sim, so it says so (exported, since the installer is a separate process; an r1.env override still wins).

    Exactly requirements.txt, never requirements*.txt — the glob is what made r1ctl install fail on every run (BIN-472). It also matched r1pro_perception/requirements.humble.txt, the robot's exact Jetson pins, whose open3d==0.18.0 is a py3.10 wheel; rap-1 is Jazzy on py3.12, where none exists, so pip refused it. The abort landed before .venv-shim is created, and since the manipulation server was split into a ZMQ agent plus a ROS-free shim, no shim venv means no :8004 at all — no plan_execute, no joint_state, no solve_ik, with the adapter answering and nothing behind it. One word of glob, three layers from the symptom. The sim-side installer (install_sim_deps_sim.sh) takes the same exact name plus requirements.$ROS_DISTRO.txt where one exists, both in a single pip invocation so the tighter pin wins in one resolve. Both rules are pinned by a test in the sim repo (tools/test_requirements_selection.py). - A submodule that git calls fine but whose working tree is empty gets forced. That is the state an interrupted clone --recurse-submodules leaves — and nothing ordinary repairs it: git submodule update --init --recursive compares the recorded commit with the submodule's HEAD, finds them equal, and never looks at the working tree (measured: a no-op). So every install failed identically with pip's "Neither 'setup.py' nor 'pyproject.toml' found" for as long as the state lasted. Only --force rewrites the files, and install runs it — on both the clone and the pull path — asking the question that matters: are the files there. Same correction as _installed probing venv interpreters that run rather than directories that exist (BIN-237).


A saved place expires when the robot restarts

Teaching a place works; keeping it does not, and the reason is the frame rather than the file. A waypoint is {x, y, yaw_deg} in map frame, and the map frame is rebuilt at every bring-up: 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 R1 Fast-LIO2 publishes map→odom as an identity static TF. Either way the origin is wherever the robot happened to be standing.

The store survives a restart. The frame does not. So "kitchen table" silently becomes a different physical place — and before BIN-213 the robot drove there and reported success, which reads as a nav fault and sends you looking in nav2.

Now r1ctl up mints R1_NAV_SESSION (and prints it), save_waypoint stamps it, list_waypoints returns stale per place plus a count, and navigate_to_named refuses a stale place and tells you to re-teach it. allow_stale=true is the escape if you know the frame survived.

Expect to re-teach every place once after this lands: anything saved before stamping has no id and reads as stale, which is the honest answer rather than an optimistic guess.

A stale place is refused, not forgotten — and since BIN-550 that is a property of the schema rather than of the code that happens to read it. The store is a SQLite table whose columns declare which of BIN-546's kinds they hold: the name is a definition (a human authored it; it does not become false when the robot reboots), the pose and its session stamp are beliefs (meaningless outside the bring-up that observed them). So a foreign epoch resolves the coordinates to unknown while the row stays, which is what lets the UI say "there is a place called Table right and it needs re-teaching" instead of saying nothing; prune raises rather than delete a row holding a definition. Re-teaching it — drive there, save_waypoint again — is the whole recovery, and you cannot re-teach a name nobody remembers.

The real fix — save the sim map and relaunch with slam:=false map:=<path> + AMCL; on the robot, dump the Fast-LIO PCD and publish a genuine map→odom correction — is still open (BIN-213).

One approach no longer spends a minute realigning

approach_object's Nav2 fine dock (FollowPath on FinePrecise) used a flat 60 s budget, and a flat budget is a promise to spend it: the goal checker either passes early or never. Measured on rap-1, it burned all 60 s, gave up 0.477 m from the box face, and the open-loop /cmd_vel creep that follows closed the last 12 cm in seconds — every approach, and a grasp docks more than once. That is what "it realigns five times until it is happy" was.

The dock is now skipped when the base is already inside the standoff, and otherwise budgeted by the gap it has to close (8 s + 2.5 × gap / 0.10 m/s, ceiling unchanged at 60 s). approach_object also reports fine_dock_s. Both decisions come from a distance it already computed, so they are free (BIN-217).

Still open on the same trace: compute_grasp_joints takes 98 s and runs twice per attempt, which is the largest single line item in a pick and needs on-robot measurement to fix.

r1.env — the knobs

r1.env sits next to r1ctl and is exported wholesale, so anything in it also reaches the sim scripts and the service windows.

A broken line in r1.env stops the launcher — and a $( ) substitution is enough to do it

r1.env is sourced first thing, under set -euo pipefail, before r1ctl prints a word, so a line in it that fails takes the launcher down there. It used to do so with no output at all and a non-zero exit — indistinguishable from "r1ctl is broken", in the one file operators are invited to edit. It now names the file, the line number and the command.

The trap worth internalising: a command substitution's subshell inherits set -e, so any non-zero command inside it aborts the assignment and -e then kills r1ctl. This line is fatal in r1.env

VISION_PYTHON="$( . sim/mac_ros_env.sh >/dev/null 2>&1; command -v python3 )"

— because that script probes a local_setup.bash that need not exist, while the identical line written in front of the command works, since there it runs in your own shell:

VISION_PYTHON="$( . sim/mac_ros_env.sh >/dev/null 2>&1; command -v python3 )" ./r1ctl up

Prefer literal values in r1.env. R1_ENV=/dev/null ./r1ctl status confirms the file is the cause.

r1ctl writes back exactly two values — everything else is yours to set

env/up persist only SAM3_SERVER_URL and the BINABIK_SECRETS_FILE pointer (and into galaxea_agent/.env). Every other key you must add by hand: most are commented out in r1.env.example, and several appear in it nowhere at allVISION_PORT, VISION_DIR, GALAXEA_ISAAC_BRANCH, MCP_PORTS, MCP_PORTS_OPTIONAL, COLCON_WS. Read r1ctl itself when in doubt; this table is the summary.

Seven of these defaults are deliberate and pinned by tests — don't tidy them (BIN-177)

Each one is a default whose absence has already cost disk, money, or every grasp, so tests/test_sim_scripts.sh asserts it and a flip fails the suite. Read this before you "correct" an apparent inconsistency:

Default Why it is that way
SIM_AUTOLAUNCH=0 so an unattended up never boots a second Gazebo
GRASP_POLICY=deterministic grasp-service's own default is tiered; the robot's is deliberately the opposite, and it is r1ctl that makes it so. Flip it and every deployed R1 pays an Anthropic call per rescue
GRASP_STRATEGY_PACK=r1 grasp-service defaults to minimal, whose tools (where_to_stand/go_to_stance/pick) the adapter does not expose — a drift here is every grasp failing at the first unknown tool
CAP_URL/LOCATE_URL built from $ADAPTER_PORT never the archived :9206/:9207 (BIN-135)
the grasp window sources grasp-service/.env the only way ANTHROPIC_API_KEY reaches the process — a tmux window inherits the tmux server's env, not r1ctl's — and the only safe way, since a launch-line secret lands in the pane and in /tmp/r1_grasp.log (BIN-156)

The suite has no line coverage to gate on (it is bash), so it ratchets on check count instead — two floors, because one section is skipped without tmux (400 checks without, 425 with, as of 2026-08-07). A single tmux-less floor would leave the difference as slack exactly where the suite really runs. Raise a floor when you add checks; never lower one to make a run pass. The guards are mutation-checked: flipping each default, pointing CAP_URL back at :9206, dropping the .env source, removing :9230 from the port list, or making down reach for galaxea's ports each turns the suite red.

Two things the ratchet cannot see, both of which have bitten here: a check that runs, counts, and could not have failed — so bash tests/test_lib.sh feeds every shared assertion helper a known-passing and a known-failing input — and a fixture that makes the launcher do something other than what the check claims to pin. All four suites plus tests/golden/run.sh --check (48 recorded traces, 24 per backend) are the gate.

Var Default Meaning
RAP_ROOT $HOME/rap where the robot repos live. GALAXEA_DIR / ADAPTER_DIR / GRASP_DIR / VISION_DIR / WORLD_DIR / GALAXEA_ISAAC_DIR override individually.
SESSION r1 the MCP tmux session. Nothing else may start with this name — BIN-123.
ADAPTER_PORT / GRASP_PORT 9220 / 9210 the two binabik SSE ports.
GRASP_STRATEGY_PACK r1 passed to grasp-service (its own default is minimal).
GRASP_POLICY deterministic r1ctl's default; grasp-service's own default is tiered. See the grasp table.
GRASP_SELF_IMPROVE off passed on the grasp launch line. Gates the local lesson store only — which switch is live.
MCP_PORTS / MCP_PORTS_OPTIONAL / MCP_PORT_ASSERT / MCP_PORT_WAIT_S see above the port assertion.
GALAXEA_SESSION galaxea_mcp_local the session name galaxea's launcher defaults to; killed on reset so a relaunch can't collide.
GALAXEA_LOG /tmp/r1_galaxea_mcp.log where the galaxea launcher's output lands.
GITHUB_ORG git@github.com:binabik-ai clone base. The SSH form also triggers the HTTPS→SSH rewrite for robot-mcp-kit.
UV uv on PATH, else ~/.local/bin/uv set it if uv isn't on a non-interactive PATH.
SIM_ENABLE 1 r1ctl manages the sim. 0 = you bring it up yourself first.
SIM_LAUNCH_CMD bash sim/gazebo_sim.sh the sim launcher — must be non-blocking / start into its own session.
SIM_SESSION robot the sim's tmux session; reused when already up.
SIM_WAIT_S 150 how long up waits for move_group.
SIM_AUTOLAUNCH 0 launch the sim unattended (no TTY). Hoisted into r1ctl's config block with the other sim toggles by BIN-177 (it was an inline ${SIM_AUTOLAUNCH:-0}); still not in r1.env.example.
GALAXEA_ISAAC_BRANCH main the unified tree — one branch for the sim and the robot since BIN-276, selecting per host by $ROS_DISTRO and backend:=. It used to be jazzy, with main the old Ignition-Fortress port; that is no longer true. The pre-collapse tips are frozen as frozen/humble and frozen/jazzy — reference only, do not point this at them, they take no commits.
COLCON_WS / WS /home/ros/colcon_ws — each script's own default where the sim workspace is sourced (COLCON_WS, read by gazebo_sim.sh) and built (WS, read by install_sim_deps.sh). Set either and r1ctl sets the other; set both to different paths and it exits (BIN-149). With neither set on a host where the default isn't creatable by you, up warns up front instead of dying forty lines into pip output.
R1_ROS_PYTHON_CHECK 1 the pane-python3 preflight — what it catches. 0 skips it. Linux only.
R1_SERVE_PROXY_DETECT 1 probe tailscale serve and bind loopback under a proxy — why. 0 keeps the wildcard unconditionally.
BINABIK_PORTS_REQUIRED / BINABIK_PORT_WAIT_S 9220 9210 / 20 our own layers' assert — above. Shares MCP_PORT_ASSERT.
R1_CMD_DIR /tmp where each window's launch body is written (r1_<window>.cmd.sh) — the post-mortem for a dead window, and re-runnable by hand.
R1_LOG_DIR /tmp where each window's r1_<window>.log is written. status reads these to report what each service resolved (BIN-582), so writer and reader share one variable — and the credential report can be tested against fixture logs instead of a real robot's /tmp.
R1_ROSDEP_HOME / ROSDEP_SOURCE_PATH / ROSDEP_SKIP_KEYS / SKIP_ROSDEP / ROSDEP_STRICT ~/.cache/binabik/rosdep / (exported) / (exported) / 0 / 0 the root-free rosdep shim and its knobs — the sim's dependencies. ROSDEP_STRICT=1 makes an unusable resolver a non-zero exit, for provisioning scripts.
R1_WAYPOINTS_FILE $RAP_ROOT/state/waypoints.json the taught-places store, deliberately outside the adapter checkout — BIN-214. Since BIN-550 the store is SQLite and this variable names its directory: $RAP_ROOT/state/waypoints.db. The store class is robot-mcp-kit's since BIN-573; the adapter keeps only its schema and its per-column kind map, which the kit's per-table vocabulary cannot express (BIN-574).
R1_REINSTALL / R1_ENV / R1CTL_ALLOW_SESSION_PREFIX force a re-install of every component · point at another env file · override the session-name refusal.
SIM_REINSTALL 0 1 forces the full sim provision even when the probe calls the workspace current, leaving the other components to their own probes — the narrow half of R1_REINSTALL. r1ctl install --sim is the same switch as a flag. What the probe checks.
SAM3_SERVER_URL (unset → a hardcoded lab address) the segmentation backend. Read this before debugging perceive.
GEMINI_API_KEY (from the store, or the consuming service's own .env) scene grounding/segmentation (binabik-r1-vision's grounder, i.e. capture_scene) + the sim's perception. Not the watchdog — since BIN-328 the robot runs no VLM of its own; the surveillance observer judges in the brain with the brain's key.
MEMORY_URL / ROBOT_ID / MEMORY_TOKEN / WATCH_PUSH_PROXY (empty) fleet grasp-learning — see below.
VISION_* off the scene service + its segmenter chain — see below.
DOCKING_* off the AprilTag marker servo (:9250, KOE-35) — DOCKING_ENABLE, DOCKING_ROBOT (r1|g2|null), DOCKING_MARKER_TOPIC, DOCKING_PYTHON. Opt-in because it moves the base, not because it costs money. Needs VISION_ENABLE=1 too: /marker/pose comes from :9230, and up warns when the flags disagree. Installed with the [servo] extra — mcp is an extra of that repo, so a plain install leaves a venv with no server in it.
R1_SECRETS_FILE / R1_SECRETS_DIR (empty) / secrets/ beside r1ctl this robot's identity at the secret store — see below. Normally neither is set.
R1_KEY_CACHE / R1_KEY_CACHE_DIR 1 / $RAP_ROOT/state/keys where a service may keep the credentials it declared cacheable — see below.

A uv venv that imports rclpy must match the distro's Python minor version

Any service whose venv imports rclpybinabik-r1-vision and, since KOE-36, binabik-docking — needs its venv built on the distro's Python (Jazzy 3.12, Humble 3.10), and needs the matching /opt/ros/<distro>/setup.bash. up preflights that import and prints the uv venv --python … rebuild command rather than letting a mismatched service come up looking healthy.

This bit twice, so it is written down: the retired ros2-memory and mcp-perception-buffer both defaulted their *_ROS_SETUP to /opt/ros/humble/setup.bash while the RAP sim stack is Jazzy. A new ROS-importing service should default to no distro at all and fail loudly, rather than default to the wrong one and fail obscurely.

Credentials from a store, not from r1.env (BIN-426)

Every API key on this robot used to live in a hand-edited file — r1.env, grasp-service/.env, galaxea_agent/.env — and two of them, MEMORY_TOKEN and PERCEPTION_EVENT_SECRET, must match the brain host exactly. Rotating one meant editing files on two machines and hoping no third copy was missed. A partial rotation does not fail loudly; it breaks auth silently.

Each service now resolves its own credentials through robot-mcp-kit's KeyRegistry. r1ctl supplies the identity to resolve with; the keys themselves never touch a config file.

Giving the robot an identity

On Google Secret Manager, one command creates it (BIN-503) — see Provisioning it below for what it does and why it is not part of up:

./r1ctl provision                            # once per robot; ROBOT_ID + GOOGLE_SECRETS_PROJECT
./r1ctl up

By hand — for Infisical, or to write the file yourself:

cp secrets/example.env secrets/rap-1.env     # <ROBOT_ID>.env, or <short hostname>.env
chmod 600 secrets/rap-1.env                  # r1ctl warns if you don't
./r1ctl up                                   # …or: ./r1ctl up --secrets-file /path/to/id.env

Discovery, in order: --secrets-file$R1_SECRETS_FILEsecrets/$ROBOT_ID.envsecrets/<short hostname>.envnothing. ROBOT_ID comes first because it is already this robot's fleet identity — it scopes the central memory and names the robot in the brain's Fleet admin — so the store scope and the memory scope cannot drift apart.

There is deliberately no secrets/default.env

One shared file would hand every robot in a fleet the same machine identity, which is exactly the host-level identity a store exists to replace — and it would do so silently. Finding nothing means no store: keys come from r1.env and the service .env files, as before. That is the migration path, not a fault, and r1ctl status says so.

A file you named and that does not exist is a different matter: up refuses. Being handed an identity and quietly ignoring it is the failure this whole change removes.

What goes in it

The bootstrap credential and which backend to use — BINABIK_SECRETS_BACKEND is env, google or infisical; unset falls back to the environment and an unrecognised value is fatal. Never the API keys. See secrets/example.env for both backends' settings.

Unlike brainctl there is no create-time constraint (BIN-78 — docker env is fixed at docker create): these are tmux windows, so a rotated key reaches a restarted window with no recreate, and a refresh in place needs no restart at all.

Provisioning it: r1ctl provision (BIN-503)

Until BIN-503 nothing created an identity. secrets/example.env delegated the account, the grant and the key to a gcloud recipe run by hand — and it had been run once, for a single shared project-wide admin that every robot then read the same secrets with (BIN-482). r1ctl provision is the robot-side front end over robot-mcp-kit's provisioning library; the brain's Credentials tab and a CLI are the other two front ends over the same code.

./r1ctl provision                                      # ROBOT_ID + GOOGLE_SECRETS_PROJECT from r1.env
./r1ctl provision --project-id binabik-dev --robot-id rap-1 --site-id zurich
./r1ctl provision --new-key                            # rotate an existing account's key

What it produces, all idempotent:

  1. a service account for this robot alone (robot-<id>@<project>.iam.gserviceaccount.com);
  2. one conditional secretAccessor binding covering its own prefixes — robot__<id>__*, site__<id>__* with --site-id — plus the unprefixed company defaults, and nothing else. One binding rather than a per-secret loop, so adding a secret needs no IAM change and deleting one leaves no orphaned binding (BIN-502 decision 3);
  3. the identity itself: a key file at ~/.config/binabik/<robot>-sa.json (mode 600, directory 700), or an iam.serviceAccountTokenCreator grant where constraints/iam.disableServiceAccountKeyCreation blocks key creation;
  4. secrets/$ROBOT_ID.env — the file discovery already looks for — mode 600, written key by key so an existing file keeps its other settings;
  5. the backend's SDK (google-cloud-secret-manager) in the venvs r1ctl builds itself — the galaxea MCP venvs and vision's --system-site-packages one, neither of which has a lockfile for it to be declared in. A uv sync-managed service declares it instead — see below.

Verified live against binabik-dev: a provisioned identity cannot create, overwrite or list secrets, and cannot read another robot's (BIN-503 step 5). The condition is keyed on the project number, not its id — an id-keyed condition matched nothing, and through the double-negated defaults clause that swung the whole expression universally true, granting every secret to every identity while reading as least privilege.

It is not part of install or up, and must not become part of either

A host must never hold standing admin in order to obtain its own non-admin credential (BIN-482, restated by BIN-502) — and install and up both run unattended, from a wrapper, a reset or cron. up and status name this command when the robot has no identity; they never run it. The launcher reports, it does not resolve.

Authority is the operator's, and transient. gcloud on the robot host is preferred, because the token is then minted inside the child process and r1ctl never sees it at all. Failing that: $BINABIK_OPERATOR_TOKEN, or a read -rs prompt for a token pasted from gcloud auth print-access-token on a machine that has the SDK — which is what a headless robot has, and the same fallback BIN-502 decision 1 gives the browser flow. The token is never written to disk, never echoed, and never passed as an argument: argv is readable with ps by every account on the host, and phyai01 is shared by design.

Two traps the command exists to close, both of the "looks healthy, resolved nothing" family this whole section is about:

  • --secrets-file <path> outside the by-name chain provisions perfectly and leaves an identity up cannot find. The command computes whether discovery would find it and says so.
  • A GOOGLE_APPLICATION_CREDENTIALS naming a key that was never minted is fatal in up, so the impersonation outcome writes no path and clears a stale one. For the same reason provision does not run the discovery/refusal path at all: in strict mode it refuses exactly the state this command exists to repair.

ROBOT_ID is required and does not fall back to the hostname the way the identity file does: it is the secret-name prefix the services ask under, so provisioning under any other name grants a prefix nothing reads — a credential resolving from nowhere while every binding looks right. A value outside [A-Za-z0-9_-] is refused before any call is made.

How it reaches each service

Sourced into every window after that window's own env file, so a per-robot value wins while anything not yet migrated keeps working from the file it is in today. r1ctl owns four windows (adapter, grasp, world, vision) and writes the line into the generated body — cat $R1_CMD_DIR/r1_grasp.cmd.sh shows it. galaxea's servers are launched by its own start_mcp_local.sh, so they get a pointer: r1ctl writes BINABIK_SECRETS_FILE=<path> into galaxea_agent/.env and that script sources it after the .env. A path is configuration; the credential stays in the file it points at.

Which of several secrets by one name is this robot's (BIN-482)

The identity says who this robot is to the store. The scope says whose secrets it asks for, and provision grants exactly those prefixes — so the two have to agree or the grant covers a prefix nothing reads:

robot__rap-1__GEMINI_API_KEY     ← this robot's own
site__zurich__GEMINI_API_KEY     ← one site's robots share it, with SITE_ID
GEMINI_API_KEY                   ← the unprefixed company default, always last

r1ctl composes it once from ROBOT_ID (+ optional SITE_ID) and passes BINABIK_SECRET_SCOPE to every consumer, by whichever route reaches it: grasp's and vision's launch lines, galaxea_agent/.env for the MCP servers, and the sim session's tmux environment for r1pro_perception's Gemini node. An explicit BINABIK_SECRET_SCOPE in r1.env replaces the composed rungs wholesale — that is the kit's rule, and provision warns when what it grants is then not what anything asks for.

Until BIN-482 only grasp's window had ROBOT_ID in it, and there for the central memory rather than for this. The other three asked bare names, so a per-robot secret could be granted to a robot's own account and never requested — with the company default answering instead, and nothing anywhere saying so, because a bare name resolving also logs from store. r1ctl up and r1ctl status print the composed scope now, and say when there is none.

The scope is on the launch line, not in the identity file

That file is already sourced by every consumer, so one line in it would have covered all four — and would have been sourced last in three of them while losing to grasp's own assignments in the fourth. One robot with two answers to "who am I" is the same defect wearing a different hat. Leaving ROBOT_ID unset is still a working stack: it reads the company defaults, which is the pre-scope behaviour, and the environment fallback is unaffected either way since it only ever reads bare names.

Which robot-side services resolve what

Service Names Cacheable?
grasp-service MEMORY_TOKEN, ANTHROPIC_API_KEY, ANTHROPIC_AUTH_TOKEN yes — without them it comes up crippled
binabik-r1-vision GEMINI_API_KEY, R1_VISION_MODAL_TOKEN no
galaxea_agent (manipulation) GEMINI_API_KEY, OPENAI_API_KEY no
r1-abstraction (none — it holds no credential)

Nothing is declared required anywhere on the robot. Every one of these has a defined behaviour when absent — GRASP_POLICY=deterministic never invokes the recovery tier, the memory may run unauthenticated on the tailnet, the segmenter chain falls through to box polygons — and a declaration that is wrong for a valid deployment is worse than none.

r1ctl writes no credential — so a key in r1.env alone reaches nothing

Where to put a key: the secret store this robot's identity reaches, or the .env of the service that needs it. Not r1.env — putting it only there is the one placement that looks configured and does nothing.

The launcher hands each service the means to resolve its credentials — the identity file, whose path is configuration — and each service resolves its own through the registry. It does not carry the values. A launcher that also copied them around would be a second distribution mechanism, and the two disagree the moment one of them is rotated; it also meant "which copy is live" needed three answers. BIN-439 removed the GEMINI_API_KEY and ANTHROPIC_API_KEY writes for exactly that reason.

It missed one. OPENAI_API_KEY — the bearer for the held-check's OpenAI-compatible endpoint — was still mirrored into galaxea_agent/.env until BIN-351, because it rode along in a list that is otherwise endpoints and model names (VLM_BACKEND, OPENAI_API_BASE, VLM_MODEL, …). Nothing noticed for months, and galaxea's own credentials.py documented the opposite as settled fact — "Neither the credential nor the API keys are ever written into .env" — while declaring OPENAI_API_KEY an optional key it resolves from the store. The claim was true of every key but that one.

It is true of all of them now, and tests/test_no_written_secrets.sh is what keeps it true: it scans every literal key r1ctl writes and fails on anything shaped like a credential (*_KEY, *_TOKEN, *_SECRET, *_PASSWORD, *_CREDENTIAL), so the next key appended to a config list fails CI instead of shipping. Two names are allow-listed because they name a file rather than hold a value: GOOGLE_APPLICATION_CREDENTIALS and BINABIK_SECRETS_FILE.

What r1ctl still writes into galaxea_agent/.env is configuration, and still should: the endpoint and model selectors (VLM_BACKEND, OPENAI_API_BASE, VLM_MODEL, LLM_MODEL, GEMINI_VLM_MODEL), the head-camera topics, SAM3_SERVER_URL, and the store pointers. Those reach the MCP windows no other way — the windows source that file, not r1ctl's environment, and leaving the held-check endpoint out of the config surface is what BIN-330 cost.

If the held-check stopped working after an upgrade

You were relying on the old mirror. r1ctl env/up now say so when OPENAI_API_KEY is set in r1.env: it prints that the value is no longer copied and names the two places it is actually read from. Move it to the store, or to galaxea_agent/.env by hand. "dummy" is a normal value here — a local vLLM ignores the bearer — which is why it is not worth a round-trip to a secret store.

The local cache — per name, not per registry

R1_KEY_CACHE_DIR (default $RAP_ROOT/state/keys, one file per service, mode 600) is where a service may keep a resolved copy, so the robot comes up working when the store is unreachable — a robot on userspace-networking Tailscale cannot reach it during the very outage the cache exists for. $RAP_ROOT because install re-clones every service checkout and that directory is the only thing surviving a container reset.

r1ctl only says where. Which names may be written is each service's own cacheable declaration (the table above), which is what lets the cache exist at all while BIN-364 decision 12 keeps a licensing key off the disk. R1_KEY_CACHE=0 keeps nothing on disk.

Every robot-side consumer resolves its own — including the ROS node

All four go through robot-mcp-kit's ServiceCredentials, so there is one pattern rather than one per repo: a ServiceKeys declaration plus the accessors that give those names meaning, with the registry singleton, the refresh, the bootstrap and the status line coming from the kit.

The last to convert was r1pro_perception's segmentation_node_gemini.py, which is why GEMINI_API_KEY no longer has to live in a file. The sim/robot session gets BINABIK_SECRETS_FILE — a path — via tmux setenv, and the node resolves for itself.

Two properties of that node matter before editing it:

  • Its kit import is guarded, and since BIN-439 the guard resolves nothing rather than falling back to os.environ. install_sim_deps_sim.sh installs the kit from the sibling clone r1ctl maintains (BINABIK_KIT_DIR), so no perception install needs SSH of its own; a robot that has not re-run that install has no kit, no key, and degrades to plain SAM 3. Requirement 7's migration path is unaffected — the registry's own chain ends at the environment, so a robot with no store still resolves from its .env, through one path with the source recorded. tools/test_perception_credentials.py pins both halves.
  • No credential is tmux setenv'd at all. Only BINABIK_SECRETS_FILE is. Pushing the key put it in the tmux server's environment, where every window in the session inherits it and tmux showenv prints it — and made the launcher a second place the credential lived, which disagrees with the store the moment either is rotated.

r1pro_perception declares nothing required and nothing cacheable: the node's own RuntimeError stays the refusal, and a robot that cannot reach the store falls through to SAM 3 segmentation — a degradation, not an outage.

What the store cannot take over yet

BIN-239's acceptance criterion is "a robot starts with no API key in any .env". Every credential a robot-side service consumes now reaches it from the store; GEMINI_API_KEY was the last holdout. Two limits remain, and neither is a consumer that cannot resolve:

  • MEMORY_TOKEN is in no store yet. It must be byte-identical to the brain host's, and the memory server already resolves it through the registry — so seeding a wrong value would 401 every robot in the fleet, silently. It needs the brain's real value and a coordinated restart of both sides (BIN-430).
  • Two credentials are still passed on launch lines, so they land in plaintext in the pane body files (cat /tmp/r1_<win>.cmd.sh, which is also the post-mortem for a dead window): MEMORY_TOKEN on grasp's, and R1_VISION_MODAL_TOKENr1.env's VISION_MODAL_TOKEN, renamed on the way — on vision's. Both are left alone deliberately: MEMORY_TOKEN's delivery is BIN-430's subject and a partial move is the failure BIN-239 opens with, and no Modal secret can be seeded until BIN-427 settles which of its four names is canonical. Until then they are the exception to "the launcher handles no secret values", and worth knowing about when you read a pane body.
  • A host with no store has no r1ctl path to a key. r1ctl env used to prompt for GEMINI_API_KEY and write it into two files; since BIN-439 it writes none. Put the key in the file the consuming window sources — galaxea_agent/.env for the MCP servers, grasp-service/.env for the recovery tier — and the registry's chain resolves it from there.

    That covers the sim's perception node too since BIN-439: r1ctl exports BINABIK_AGENT_ENV (a path), both sim launchers push it into the sim session, and the pane body fills any variable it does not already have from galaxea_agent/.env. Before that the sim session sourced no .env at all and a store was the only route.

    It fills gaps only, unlike every other window in the stack, where set -a; . .env is right because a tmux window inherits nothing. This session is the exception: the launcher pushes DISPLAY, SAM3_SERVER_URL and the store identity into it, resolved for that run, and a plain source would let a stale SAM3_SERVER_URL in the file win — BIN-137's failure by a new route.

    Do not put the key in r1.env expecting it to reach the sim. r1ctl sources r1.env under set -a, so it is exported and inherited by the sim launcher — but a tmux window's environment comes from the tmux server, which takes it from whoever started the server. If that up starts it the value propagates; if a server is already running it does not, and up reuses the robot session by design. Measured both ways, 2026-08-28: luck, not a configuration route.

If a consumer ever appears that genuinely cannot resolve — a third-party ROS node, a vendor binary — the answer is the other half of requirement 3: r1ctl resolves on its behalf and exports the value. Nothing needs that today.

Note the two sources are not a conflict while both exist: the store wins over a .env inside the chain, so a rotated store value wins in every consumer even where a stale copy is lying around — and since BIN-439 the credential line says, per name, which of the two answered.

Proving it did something

./r1ctl status                        # which identity, and which backend it names
grep credentials /tmp/r1_grasp.log    # what that identity actually reached
rap-1-grasp: 2/3 credentials (1 from store, 1 from environment) | absent (optional) …

from store is the only proof. r1ctl status deliberately does not claim it — only the service knows what it asked for and what came back (that is arguably too modest, and BIN-582 asks it to run the grep itself). Reading the rest of the line:

On the boot line Means Recovers on its own
from environment the store holds nothing under that name, and the pre-registry path supplied it n/a — it is working
from cache the store could not be reached; these are whatever it last said depends which of the two below it is
store unavailable: … an outage. Wait; the cache is the right answer meanwhile yes
IDENTITY REFUSED — this will not fix itself the store answered and rejected this identity (401/403). Wrong service account, or a missing IAM binding no
STORE MISCONFIGURED — this will not fix itself: … this host could not ask — no SDK in the venv, no project id, no URL. Nothing reached the store at all no

The last one is new in robot-mcp-kit 0.11.0 and is logged at ERROR, where the other two are WARNING (BIN-572). It exists because the three used to be one message: a permanent packaging fault printed store unavailable, an outage's wording, and an outage's wording licenses waiting — so grasp-service ran a whole day on cached credentials with nothing able to reach the store.

Declare a backend's SDK; never uv pip install it

Each store backend's SDK is an extra on the kit (robot-mcp-kit[google], robot-mcp-kit[infisical]), so depending on the kit does not install it — deliberately, since grpcio and botocore are not weight for a deployment resolving from os.environ.

A service with a lockfile must declare the extra. uv sync makes a venv equal its lockfile, which means it removes anything undeclared — and uv sync is what r1ctl runs on install and what a deploy runs. Installing the SDK out of band therefore holds only until the next sync. That is not a hypothetical: r1ctl used to uv pip install google-cloud-secret-manager into grasp-service's venv, one uv sync on 2026-09-10 removed it, and the service spent the day on ~/rap/state/keys/grasp.json — a cache no rotation can reach — printing 2/3 credentials (2 from cache) and working normally (BIN-572).

Which backend a robot uses is a property of its identity, not of the code, and that principle is honoured where it belongs: the kit keeps google optional, so a deployment on env or Infisical carries no grpcio. What a consumer cannot do is leave the extra undeclared and hope.

So, per robot-side service:

Service Declares Why
grasp-service robot-mcp-kit[mcp,recovery,google] resolves MEMORY_TOKEN + the Anthropic key
binabik-r1-vision should — BIN-583 resolves GEMINI_API_KEY; today it only works because its --system-site-packages venv sees a system-wide copy
r1-abstraction nothing resolves no credential at all
binabik-world-state nothing resolves no credential at all — the paid key lives where the spend happens, which is vision
galaxea's MCP venvs n/a no pyproject, no lockfile, so r1ctl installing out of band is the only option and is correct

Each of those four repos has a tests/test_store_packaging.py holding its position — positive where the extra is needed, and the converse where it is not, so growing a credential without the declaration fails the suite rather than a boot line nobody reads.

The sim on RAP — the load-bearing settings

Running the sim on your own hardware instead? Running the simulator covers the Linux/VM and macOS paths.

A mismatch on any one of these produces a running sim with no camera

The RAP VMs (rap-0, rap-1) run the Gazebo sim — not Isaac, despite the repo name.

  • galaxea_isaac_moveit on mainr1ctl checks it out for you (GALAXEA_ISAAC_BRANCH, default main). This entry used to say the opposite, and following it now means trying to check out a branch that no longer exists: jazzy was merged into main and deleted upstream, with frozen/jazzy kept as the snapshot (why). After switching branches by hand, rebuild the r1pro_* colcon packages — a stale install/ keeps running the old code, and that is the half of the old warning that is still true. rap-0 and rap-1 must run the same branch; a mismatch is exactly what broke rap-1's camera while rap-0 worked.
  • DISPLAY=:0 is required for camera rendering. Gazebo's camera sensors (and RViz) render on the GPU via the robot's Xorg on :0. A headless gz sim runs but its cameras produce no frames (RViz dies with no Qt platform plugin "xcb"). sim/gazebo_sim.sh pushes DISPLAY into the sim session and warns when it isn't reachable.
  • RMW = default FastDDS (rmw_fastrtps_cpp). The Zenoh / CycloneDDS configs in the tree are unused — don't chase a DDS setting for a missing camera; it's a red herring.
  • Camera topics once up: /head_camera/image (+ depth_image, + left_/right_wrist_camera/*), bridged gz→ROS by gz_ros_bridge in sim.launch.py.

up installs only what is missing — and never rebuilds a healthy sim

up probes each component on its own — the galaxea MCP venvs, the adapter venv, the grasp venv, the opt-in vision stack, and the sim — and installs only those that fail their probe.

It used to be all-or-nothing, and the cost was not theoretical: one missing venv (after a kit bump, or a reset that wiped an overlay) re-ran the entire Gazebo provision — checkout, pull, submodule update, rosdep, a full colcon build — on a host where the sim was already built and working. Minutes of rebuild, on a command whose job is bring the stack up, not provision the host.

The sim gets a fast path on top of that. It is skipped, with one line saying so, when all of:

Condition Why it is in the list
on GALAXEA_ISAAC_BRANCH (default main) a stale install/ from another branch is the no-camera failure — and it looks like a working sim until a grasp needs a picture
that branch still exists on origin, and HEAD is not behind it see the warning below
no submodule is empty r1pro_assets carries the meshes
the r1pro_* packages are built in $COLCON_WS/install, and $WS/src is not a dangling symlink --symlink-install leaves a dangling link after an overlay wipe

Any one false and the full provision runs.

\"Not behind origin\" must not be asked against the tracking ref

git rev-list HEAD..origin/<branch> for a branch that no longer exists upstream compares HEAD with itself and returns 0 — so an arbitrarily stale checkout reports as current. That is exactly how galaxea_isaac_moveit read as up to date while sitting 124 commits back on the deleted jazzy (BIN-478).

So the probe asks git ls-remote --exit-code --heads origin <branch> first, and compares against a real fetch (FETCH_HEAD), never origin/<branch>. A deleted branch is caught twice over — the ls-remote says so in as many words, and the fetch would fail anyway — which is deliberate: this is the check whose false "yes" ships a sim with no camera.

Forcing it, both directions. SIM_REINSTALL=1 — or r1ctl install --sim — provisions the sim whatever the probe says. R1_REINSTALL=1 still means everything. And plain r1ctl install re-runs every component unconditionally, as it always did: the probing is up's behaviour, not install's, so "reprovision this host" remains one obvious command.

The sim workspace, and the meshes

The launcher sources a colcon workspace ($COLCON_WS, /home/ros/colcon_ws in the lab). ~/rap is a mounted disk whose contents can be wiped and rebuilt with r1ctl up, but the colcon workspace lives outside it. galaxea_isaac_moveit/install_sim_deps.sh (what r1ctl install runs) is the one place that installs the sim deps and builds the workspace: it self-heals the $COLCON_WS/src/galaxea_isaac_moveit symlink after an overlay wipe, guards against Jazzy's setup.bash tripping set -u, and fails loudly (exit 1) when colcon build fails so a broken build can't masquerade as success (BIN-106). If sim source changed, rebuild: cd $COLCON_WS && colcon build (see the --symlink-install note below — with the pin in place, a --symlink-install build lets launch/config edits take effect with no rebuild at all).

--symlink-install and the setuptools pin

The sim build used to defeat itself on setuptools ≥ 80 — fixed in BIN-144

install_sim_deps.sh pip-installs the sim requirements — which pulled setuptools 83 onto both robots — and then builds with colcon build --symlink-install, whose editable path needs setup.py develop, removed in setuptools 80. Every clean install therefore ended in:

--- stderr: r1pro_controller
error: option --uninstall not recognized
Failed   <<< r1pro_controller
Aborted  <<< r1pro_pickplace
Summary: 4 packages finished, 1 failed, 1 aborted, 1 not processed

The fix (BIN-144): the script now writes setuptools<80 into the constraints file it already feeds pip (alongside numpy<2) and installs that pin explicitly, so a host that is already on 83 is brought back. --symlink-install is kept deliberately — on the robot, launch/config edits then take effect without a rebuild. Override with SETUPTOOLS_PIN=… in the environment, or SETUPTOOLS_PIN= to opt out entirely.

If setuptools ends up ≥ 80 anyway (something demanded it), the script warns loudly and builds without the flag rather than failing — the same choice sim/mac_sim_setup.sh makes on RoboStack. galaxea_isaac_moveit/tools/check_build_flags.sh runs in CI and fails if the flag and the pin ever drift apart again.

A build that fails partway now also warns that $COLCON_WS/install/r1pro_* from an earlier build is still there — a mixed workspace that can silently keep running old code — and prints the clean-rebuild command:

rm -rf $COLCON_WS/build/r1pro_* $COLCON_WS/install/r1pro_*
cd $COLCON_WS && ./install_sim_deps.sh

Meshes in the sim workspace

Link meshes are package://r1pro_description/meshes/<name>.STLalways upper-case, and no two tracked paths may differ only in case (BIN-131: such a pair checks out as a single permanently-dirty, wrong-poly file on macOS/APFS). Two CI checks enforce it; run them before committing a mesh change, and after any upstream sync into this vendored fork:

./tools/check_case_collisions.sh   # no two tracked paths collide when lowercased
./tools/check_mesh_refs.sh         # every package:// reference resolves, exact case

r1-abstraction — the adapter (:9220)

The window r1ctl launches is exactly:

cd ~/rap/r1-abstraction
MCP_TRANSPORT=sse R1_ABSTRACTION_PORT=9220 ./.venv/bin/python -m r1_abstraction.server

The upstream defaults already target a standard galaxea launch (streamable-HTTP at /mcp), so no R1_*_URL override is normally needed.

Var Default Meaning
R1_MANIP_URL http://127.0.0.1:8004/mcp galaxea r1_manipulation (streamable-HTTP)
R1_PERC_URL http://127.0.0.1:8003/mcp galaxea perception
R1_NAV_URL http://127.0.0.1:8001/mcp galaxea nav2
R1_VISION_URL http://127.0.0.1:9230/sse the opt-in scene service; when it's down, capture_scene/get_frame fall back to galaxea look (image only)
R1_DOCKING_URL http://127.0.0.1:9250/sse the opt-in marker servo; when it's down every docking tool answers {ok:false, code:"service_unavailable"}. No fallback, deliberately — there is no second way to close the last metre onto a tag
R1_DOCKING_TIMEOUT_S 600 the docking client's own call budget, separate from R1_CALL_TIMEOUT_S and much larger: one approach is 15 s of acquisition plus a 90 s budget, retried up to twice. The shared 120 s would cancel a healthy dock — and cancelling stops the base, so the symptom is a robot giving up two thirds of the way in. That client also runs with no transport retries, because re-issuing a blocking motion tool drives the robot a second time
R1_ABSTRACTION_PORT 9220 the adapter's own SSE port
R1_ABSTRACTION_HOST 0.0.0.0 the bind address. r1ctl passes 127.0.0.1 when a tailscale serve proxy already owns the port, because 0.0.0.0 over it is EADDRINUSE (BIN-234)
R1_CALL_TIMEOUT_S 120 per-call timeout to the upstream servers
R1_WAYPOINTS_FILE $RAP_ROOT/state/waypoints.json named place → {x, y, yaw_deg}. Outside the adapter checkout — it used to default to a CWD-relative waypoints.json, and r1ctl launches the adapter with cd $ADAPTER, so the taught places lived inside a git checkout that r1ctl install re-clones away (BIN-214). Since BIN-550 the store is a SQLite database, and this variable is read as the location: same directory, .db suffix — so the durability r1ctl already arranged is inherited and the robot host needs no change. R1_WAYPOINTS_DB names the database outright and wins if both are set; the JSON file itself is never read
R1_NAV_SESSION up-<UTC timestamp>, minted per up Which bring-up a saved place belongs to. A place from an earlier session is refused by navigate_to_named (BIN-213)
MCP_TRANSPORT sse the transport it serves

The adapter reads no credential, and this table used to list GEMINI_API_KEY / OBSERVER_VLM_MODEL for "the observer VLM path". That path left in BIN-328 — the visual half of the watchdog judges in the brain, with the brain's key — and nothing in r1-abstraction/src has named a credential since. BIN-426 recorded the same finding when it gave every other robot-side service a ServiceKeys: this one deliberately got none, because a declaration listing names nothing reads is exactly the disagreement a registry exists to remove.

If a remote client gets HTTP 421 "Invalid Host header": that's the MCP SDK's DNS-rebinding guard, already relaxed in these servers for tailnet use — it only bites behind a proxy that rewrites Host.


grasp-service — the L3 skill (:9210)

The window r1ctl launches (after a 5 s wait so the adapter is up), condensed:

cd ~/rap/grasp-service && set -a; [ -f .env ] && . ./.env; set +a
GRASP_STRATEGY_PACK=r1 MCP_TRANSPORT=sse GRASP_PORT=9210 GRASP_POLICY=deterministic \
  CAP_URL=http://127.0.0.1:9220/sse LOCATE_URL=http://127.0.0.1:9220/sse \
  ./.venv/bin/grasp-service

The two facts that matter: GRASP_STRATEGY_PACK=r1 (the side/top/two-hand FSMs, not the flat sim loop) and CAP_URL=LOCATE_URL=…:9220 (it targets the adapter, which is why the skill never changes per robot). An Anthropic key for the recovery tier comes from the secret store this robot's identity reaches, resolved by grasp-service itself; failing that, from grasp-service/.env, which the tmux window sources (it does not inherit r1ctl's own environment). r1ctl does not put it there — it stopped mirroring the value in BIN-439 and now writes no credential at all (why). Without a key, tiered degrades to the static path instead of failing (details).

Environment (source of truth: grasp_service/config.py):

Var Default Meaning
GRASP_STRATEGY_PACK minimal Set r1 for the R1 FSMs; minimal is the flat object-centric pick loop. r1ctl sets r1.
GRASP_POLICY tiered tiered = static attempts → recovery sub-agent (needs GRASP_LLM_MODEL + an Anthropic key); deterministic = static only, no LLM, no key. r1ctl sets deterministic. Without a usable key, tiered logs RECOVERY TIER DISABLED at startup and runs static-only — see below.
ANTHROPIC_API_KEY (empty) the recovery sub-agent's key (ANTHROPIC_AUTH_TOKEN also works). grasp-service resolves it from the secret store, else from its own environment — grasp-service/.env, which the grasp window sources. r1ctl does not write it (BIN-439) — putting it in r1.env alone does nothing (why).
CAP_URL http://127.0.0.1:9220/sse adapter URL for motion/IO tools (BIN-135; it defaulted to the archived :9206 until then).
LOCATE_URL http://127.0.0.1:9220/sse adapter URL for perception tools — the same adapter, unless a robot splits motion/IO from perception.
GRASP_PORT 9210 its own SSE port.
GRASP_HOST 0.0.0.0 the bind address — r1ctl passes 127.0.0.1 under a serve proxy, same as the adapter (BIN-234).
MCP_TRANSPORT (unset) set sse.
GRASP_LLM_MODEL claude-opus-4-8 recovery sub-agent model; only used when GRASP_POLICY=tiered.
GRASP_CALL_TIMEOUT_S 120 per-primitive wedge timeout — terminal, not retried. A healthy go_to_stance is ~34 s, so this only trips on a genuinely hung call. It is the tool client's timeout, so it also caps any larger per-primitive budget: compute_grasp_joints's declared 240 s can never take effect under it.
GRASP_DEADLINE_S 480 overall wall-clock budget; checked between attempts, so it never kills a productive motion. Bails with reason:"timeout". Also the ceiling every per-primitive budget is clamped to, so no single step can overrun the run and then be thrown away (BIN-166).
GRASP_SLOW_STEP_FRAC 0.15 share of GRASP_DEADLINE_S one primitive may consume before the engine reports it as a slow_step event + note (72 s at the defaults). Observability, not a limit — it catches the returning-but-expensive call a timeout can't (BIN-166).
GRASP_MIN_STEP_BUDGET_S 15 floor for that clamp: a step gets its declared budget or this, whichever is smaller. Without it, a deadline lapsing mid-step could hand the next call — possibly the one that forms or verifies the grip — a zero budget and report a failure for a held object.
GRASP_STATIC_ATTEMPTS 2 full static attempts before escalating (tiered only).
GRASP_RECOVER_STEPS / GRASP_RECOVER_DEADLINE_S 6 / 120 the recovery sub-agent's budget.
GRASP_RECOVER_MAX_BILLABLE 0 paid calls the recovery tier may make. A capture_scene bills on every call (the grounder always runs), so the default is none: the tier's own perceive covers a re-grasp. Set 1 only to let it look once through the paid path — the permit is then recorded on the run's notes and emitted as a recover_billable event (BIN-167).
GRASP_RECOVER_MAX_LOOKS 2 distinct observations the tier may make with no action in between (two allows the natural coarse→fine look). A repeated identical observation ends the recovery outright. Stops a tier that watches instead of acting (BIN-167).
GRASP_SURVEY_TURNS / GRASP_SURVEY_DEGREES 3 / 45 the look-around when the object isn't found: each turn is a turn_by of GRASP_SURVEY_DEGREES followed by a fresh locate, so a failed grasp sweeps up to 135° before giving up. Real only since BIN-195 — see below.
GRASP_PICK_FRAME odom frame the pick pose is expressed in.
GRASP_SELF_IMPROVE off learn winning overrides per place and bias future defaults — the local store only. With MEMORY_URL set it is not consulted at all; the central per-group toggle governs instead (which switch is live).
GRASP_LEARN_STORE grasp_lessons.db local lessons database — used when MEMORY_URL is unset, and only opened when GRASP_SELF_IMPROVE is on (BIN-573). Point it at a fresh path if it ever named a .json one: sqlite refuses a file that already holds JSON.
MEMORY_URL (empty) central robot-mcp-memory SSE URL for group-scoped, fleet-shared lessons. Recorded under either policy (on static success and on a verified static miss); only learning a new approach needs tiered, since only recovery discovers one.
ROBOT_ID (empty) this robot's fleet id/scope (e.g. rap-1) — required when MEMORY_URL is set. A short label, never this host's tailnet FQDN, and the brain driving this robot must be launched with the same value as --robot-id (why).
MEMORY_TOKEN (empty) bearer token if the central memory requires auth: either the fleet-wide token or — when the brain host sets per-robot tokens — this robot's own. It must match the brain host; a mismatch 401s every memory call, silently. Since BIN-426 grasp-service resolves it through the secret store when one is configured, which is what makes both ends read the same name instead of two hand-edited files. Brain-side semantics: Running a brain → central memory.
WATCH_PUSH_PROXY (empty) (an r1ctl var, not grasp config) — SOCKS5 proxy (socks5h://localhost:1055) for userspace-Tailscale robots; when MEMORY_URL is set, r1ctl exports it as ALL_PROXY (with NO_PROXY for localhost) so the lesson push reaches the brain host.

ROBOT_ID is a fleet label, not a hostname — and the brain must agree (BIN-494)

Keep it short: ^[A-Za-z0-9_-]+$. It is not cosmetic — robot_mcp_kit.keys.KeyScope expands it into a Secret Manager id (robot__rap-1__ANTHROPIC_API_KEY), so a value with dots in it raises inside credential resolution and every service on this robot fails to start rather than billing anything. A tailnet FQDN or IP is therefore not a legal value here, however tempting it looks.

The brain has a separate variable for where to reach this robot (ROBOT_HOST, from brainctl --robot). Its ROBOT_ID must be this value, passed as --robot-id:

# here, in r1.env:                     ROBOT_ID=rap-1
# on the brain host:  ./brainctl up pascal --robot rap-1.tailc7b34f.ts.net --robot-id rap-1

If the two disagree, two things break and neither says so where you would look. Every usage row this robot reports is discarded by the brain's ledger, so its spend reads as zero; and the planner requests skill lessons under the brain's name for this robot, which is not the name grasp-service files them under, so fleet learning silently stops reaching the planner. Brain-side detail: Running a brain → every up flag.

deterministic vs tiered

deterministic runs the static chains only — no recovery LLM, no Anthropic key, and it never writes lessons. Use it for keyless smoke tests. Switch to tiered (with GRASP_LLM_MODEL + a key) for the recovery tier and, with MEMORY_URL + ROBOT_ID, fleet-shared grasp lessons.

tiered without a usable key — degraded, not broken (BIN-156)

The recovery tier can no longer fail a grasp. It used to: rap-1 ran GRASP_POLICY=tiered against a venv with no anthropic (the SDK was an extra that r1ctl install's plain uv sync never pulled), so the first escalation raised ModuleNotFoundError out of RecoveryAgent.run(), through the engine — which catches only tool errors — and out of the MCP tool boundary. Every grasp() came back:

{"_value": "Error executing tool grasp: No module named 'anthropic'"}

The flagship skill was unusable, and the static path's real reason (missed, unreachable, …) never reached the planner. Three things now hold the invariant a recovery tier must never be able to fail the thing it exists to recover:

  1. The SDK installs. anthropic is a plain grasp-service dependency (robot-mcp-kit[mcp,recovery]), declared at the site that imports it, so r1ctl install installs it. tiered is grasp-service's default policy; its dependency does not belong behind an extra.
  2. A startup check, not a first-grasp surprise. A tiered launch asks whether the backend is usable and, if not, logs once and doesn't wire the tier at all — the static path runs, exactly as deterministic would, and every grasp line reads policy=tiered(no-recovery). r1ctl up warns too. The message names which half is missing, because the fixes differ: the anthropic SDK is not installedr1ctl install; no Anthropic keyANTHROPIC_API_KEY in grasp-service/.env or r1.env.
  3. Containment at escalation. Anything that still escapes the tier (a stale robot-mcp-kit on the robot, an unreachable tool server during discovery) is logged, emitted as a recover_unavailable progress event, and the grasp returns its own honest reason. A brain abort (CancelledError) still unwinds the run immediately.

What you lose while degraded: recovery, and every recovery-derived fleet lesson — a tiered launch with no key generates no new approaches, exactly like deterministic (see fleet grasp-learning). What you keep: working grasps.

Fleet grasp-learning — the robot half (BIN-77)

The central memory itself lives on the brain host and is started for you (Running a brain → central memory). The robot side is opt-in via r1.env; set these, then r1ctl up:

# ~/rap/binabik-r1-host/r1.env
MEMORY_URL=http://phyai4090:9103/sse   # the brain host's central robot-mcp-memory
ROBOT_ID=rap-1                         # scopes this robot's lessons in the fleet
GRASP_POLICY=tiered                    # only recovery discovers a NEW approach; deterministic
                                       # still records outcomes, it just never learns one.
                                       # tiered needs an Anthropic key, else the tier is
                                       # disabled at startup and you get none either
                                       # The key itself does NOT go here: r1ctl writes no
                                       # credential, so a value in r1.env alone reaches nothing.
                                       # Put it in the secret store, or in grasp-service/.env —
                                       # the file the grasp window actually sources.
WATCH_PUSH_PROXY=socks5h://localhost:1055   # userspace-Tailscale robots: r1ctl exports it as
                                            # ALL_PROXY for grasp-service too, so the lesson
                                            # push reaches the brain host (NO_PROXY keeps
                                            # localhost direct)

r1ctl up prints (grasp fleet learning → http://phyai4090:9103/sse, robot_id=rap-1 via proxy, policy=tiered) when the wiring is active — and says robot_id=UNSET if you forgot it.

That wiring is necessary and not sufficient: with it in place the robot still records nothing until the central toggle is on. That is the next section, and it is the one that bites.

Which self-improve switch is live — MEMORY_URL decides (BIN-183)

grasp-service chooses its lesson store from MEMORY_URL, and the two stores have different gates:

MEMORY_URL Store Gate
unset LocalLessonStore — a JSON file on the robot (GRASP_LEARN_STORE) the robot's GRASP_SELF_IMPROVE
set RemoteLessonStore — the central robot-mcp-memory the central per-group self-improve toggle (set_self_improve; Admin → Fleet). GRASP_SELF_IMPROVE is not consulted at all

So on a fleet-wired robot — which is every robot that follows the recipe above — the robot-side flag is inert, and the switch that matters lives in the brain. It defaults to off (both the fleet default and each group's own flag), which is deliberate: the lesson worth having comes out of an LLM-driven rescue, so learning on means paying per rescue.

The failure mode this produces, and how to recognise it

While the central toggle is off, every record_params call succeeds and returns {"recorded": false, "reason": "disabled"}. Nothing errors, nothing warns, and the robot logs a normal learn progress event — the lesson store simply stays empty. That is how rap-1 spent from the day fleet learning was wired to 2026-07-31 with zero rows in /data/params, while GRASP_POLICY=tiered really was escalating and paying for rescues. The symptom looks like "learning is broken"; it was only switched off, one layer up from where everyone looked.

r1ctl up now names the switch that applies on this robot, and says so explicitly when GRASP_SELF_IMPROVE=on is set somewhere it can have no effect.

To turn central learning on: brain UI → Admin → Fleet → Self-improvement, per group (or the fleet default). Verify with get_fleet/params_status on :9103, or read it out of /data/index.db in the brain-memory container — SELECT group_id, self_improve FROM fleet_groups — since a group with self_improve = 0 records nothing for any robot in it. It was /data/fleet.json until BIN-548 moved the registry into the database; the file is gone, and the registry rebuilds itself at startup because every brain calls register_robot.

What gets recorded, under which policy. Outcomes are recorded under either policy: the engine calls _learn on a static success and on a failure that indicts the approach, so a plain deterministic grasp already writes a row (confirm/demote). What needs tiered is learning a new approach — only the recovery tier discovers one, and that is what _learn_recovered stores. So an empty store is a switch problem; a store full of demotions and no new approaches is a policy problem.

Which failures count as a demotion is the pack's own call (BIN-194). For the r1 pack the line is the moment the grippers close:

Failure reason Records?
missed — the grip was verified and there was none yes (the universal signal)
unreachable, pre_grasp_failed, close_in_failed, descend_failed, grasp_box_failed yes — emitted before the grip, so the failure is the chosen approach's own geometry
lift_failed no — with the object already squeezed, the approach is confounded with its mass and friction
still_gripped no — a precondition inherited from a prior attempt
not_found, no_strategy, unsupported_approach, timeout, tool_error no — no approach ran, and not_found has no signature to key a lesson to

This was previously keyed on missed alone, which no R1 FSM returns except after a completed verify_held — so the cardboard box (grasp_box_failed, BIN-166), the one failure that reproduces on rap-1, taught nothing however often it ran, and a confident lesson that began failing at pre-grasp planning could never be demoted. That is the second reason rap-1's store stayed empty, underneath the toggle above: with the gate finally open on 2026-07-31, a full run plus six recovery steps still recorded nothing.

Do not expect one lesson to change behaviour. A new override needs three consecutive wins with the same (approach, side, offsets) before recall returns it (ParamStore(confirm_n=3)), so the first success is invisible by design. A demotion, by contrast, applies immediately.


The scene service (binabik-r1-vision, :9230) — opt-in

By default capture_scene returns just a head-camera frame (via galaxea look, with an empty objects list). With VISION_ENABLE=1, r1ctl up launches the scene service in the vision window: a robot-side ROS 2 node (subscribing /head_camera/image + /head_camera/points + TF) that grounds and segments the scene, so capture_scene returns enumerated objects — each with an open-vocab label, a polygon, and a 3-D pick point in base_link (verified on the rap-1 sim: ~10 objects — pallet, table, shelf, containers). r1-abstraction delegates scene capture to it and falls back to image-only look whenever it is down, so the stack is safe to run without it.

It is off by default because it calls Gemini per capture (GEMINI_API_KEY required), opt-in per the cost policy. It is on-demand — it spends only when a scene tool is called, never on a cadence, which is the distinction that outlived the ambient perception buffer (retired in BIN-306).

Installing it: the flag has to be set for the install, not just the launch (BIN-143)

r1ctl install clones binabik-r1-vision and binabik-scene-perception (the robot-agnostic scene core it wraps) — but only when VISION_ENABLE=1 is set at install time, and the flag is not persisted to r1.env:

VISION_ENABLE=1 ./r1ctl install     # or put VISION_ENABLE=1 in r1.env, which persists it

Setting it in r1.env also makes it self-heal: _installed() probes the vision entry point, so the next up re-runs install for you. If something is still missing, up now names which piece (repo, core, or venv) instead of pointing at a bare r1ctl install that could never fix it — the old behaviour.

The venv is deliberately not a uv sync: uv venv --allow-existing --system-site-packages --python $VISION_PYTHON (rclpy / sensor_msgs_py / tf2_ros come from the ROS distro; --allow-existing is what keeps install re-runnable — see below) and uv pip install --no-sources -e <scene-perception> -e '.[sam]'--no-sources because the core is declared as a private git dependency uv would otherwise try to clone over HTTPS. VISION_SAM_EXTRA=0 skips the [sam] extra (~2–3 GB of torch/ ultralytics) and gives up the local-GPU tier. A preset VISION_SAM_CHECKPOINT is pre-fetched during install rather than mid-grasp.

install is safe to re-run — including after a container reset (BIN-316)

Re-running it is the normal recovery move, so every step is idempotent: repos are clone-or-pull, and each venv is a uv sync (or, for vision, uv venv --allow-existing). It used to be almost idempotent — bare uv venv errors on an existing .venv, and on the RAP containers ~/rap survives the reset while the overlay is wiped, so the vision venv outlived the reset and every post-reset install aborted there. Everything before it had run and everything after it — the sim build, galaxea's venvs — had not, which reads as a partial success rather than a failure.

:9230 is now managed like every other port (BIN-177)

It used to be in none of the three hand-written port lists — so up's reset didn't free it, down didn't stop it, and status didn't show it; a vision service that outlived its tmux window kept the port, the relaunch failed to bind, and nothing named the holder. This page documented that as a limitation, which is how a bug survives. The lists are now one derived pair, so a new port cannot be left out of one of them:

Helper Contains Used by
_binabik_ports ADAPTER_PORT GRASP_PORT WORLD_PORT VISION_PORT down
_all_ports those plus MCP_PORTS + MCP_PORTS_OPTIONAL up's reset, status

down deliberately still leaves galaxea's ports alone — that session belongs to its own launcher, and up's reset is what clears it. fuser -k 9230/tcp by hand is no longer needed.

VISION_ENABLE is still not persisted — a plain r1ctl up drops a running :9230

r1ctl writes back only SAM3_SERVER_URL and the BINABIK_SECRETS_FILE pointer, so a one-off VISION_ENABLE=1 ./r1ctl up is forgotten by the next bare up — which now also frees the port, so the service is genuinely gone rather than merely orphaned. Put VISION_ENABLE=1 in r1.env, which persists it and makes the next up self-heal a missing install too.

Configr1.env keys are VISION_*; r1ctl translates them to the service's R1_VISION_* and sources galaxea_agent/.env (configuration, and the store-identity pointer it resolves GEMINI_API_KEY through) plus a ROS 2 + colcon env into the window (it uses direct rclpy).

r1.env var Default Meaning
VISION_ENABLE 0 launch it at all.
VISION_PORT 9230 its SSE port (→ R1_VISION_PORT). Not in r1.env.example.
VISION_DIR $RAP_ROOT/binabik-r1-vision where the repo is. Not in r1.env.example.
VISION_ROS_SETUP /opt/ros/jazzy/setup.bash sourced into the window.
VISION_COLCON_SETUP $HOME/colcon_ws/install/setup.bash sourced into the window.
VISION_SEGMENTER_ORDER local_gpu,self_hosted,modal,box the fallback chain order.
VISION_SAM_CHECKPOINT (empty) ultralytics preset (sam2.1_b.pt) or a local path → local-GPU tier.
VISION_SAM_DEVICE (auto) cuda / cpu.
VISION_SAM_ALLOW_CPU 0 the local tier self-skips on CPU unless this is 1.
VISION_SAM3_SERVER_URL (from SAM3_SERVER_URL) the free self-hosted SAM 3 box.
VISION_MODAL_SAM3_URL (empty) the paid Modal SAM 3 endpoint.
VISION_MODAL_ENABLE 0 required in addition to the URL before the paid tier is ever reached.
VISION_MODAL_TOKEN (empty) auth for it.
VISION_SAM3_PROMPT object the open-vocab prompt the remote tiers use.

Service-side topic/frame config: R1_VISION_RGB_TOPIC (/head_camera/image), R1_VISION_CLOUD_TOPIC (/head_camera/points), R1_VISION_TARGET_FRAME (base_link).

The WebRTC stream sender — on wherever vision runs

get_frame is a frame snapshot, and the brain polls it at ~1.4 fps to fake a live view: a whole JPEG per frame, base64'd, relayed through the brain. Since 2026-09-11 r1ctl up also launches the sender in the stream window (index 23) and the operator's browser gets real H.264 at ~30 fps — browser↔robot over the tailnet, never through the brain (BIN-262; the brain's side is the media plane). Whether that path is direct or DERP-relayed is Tailscale's decision, not a property of the design — see the STUN/TURN warning below.

A few things about its shape are worth knowing before you operate it:

  • GStreamer is a HOST prerequisite — r1ctl does not install it, and nothing else will. It is the one thing the stream needs that no installer here provides: _install_stream builds .venv-stream with --system-site-packages on an interpreter that must already have the bindings, and binabik-r1-vision's pyproject.toml cannot declare them. On a robot or any Linux host:

    sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
                     gstreamer1.0-plugins-bad gstreamer1.0-plugins-ugly gstreamer1.0-nice \
                     gir1.2-gst-plugins-bad-1.0 gir1.2-gst-plugins-base-1.0 python3-gi
    

    Every one of those is a named element in stream_pipeline.py, not a precaution: bad is webrtcbin itself plus h264parse, good is rtph264pay and the rtpsession that carries BIN-576's nack/RTX, base is appsrc/videoconvert, nice is the ICE that forms a candidate pair at all, and the gir1.2-* pair is the typelibs behind the sender's gi.require_version calls. -ugly is the one people drop: it carries x264enc, and the encoder auto-order is nvenc → nvh264 → x264, so on any host without an NVIDIA encoder it is the encoder the sender actually reaches. Without it negotiation succeeds and there is nothing to encode with. bootstrap/rebuild.sh installs the set (stage 2b) — on a host built by that script there is nothing to do here.

    On macOS: brew install gstreamer pygobject3 libnice-gstreamerlibnice-gstreamer is a separate formula, and without it webrtcbin loads but every sink-pad request returns NULL, which looks like a code fault and is not one. Check a host in one line:

    python3 -c 'import gi; gi.require_version("Gst","1.0"); from gi.repository import Gst; \
                Gst.init(None); print(Gst.ElementFactory.find("webrtcbin"))'
    

    That is the same question r1ctl asks itself before it installs a derived stream, so a host where this prints None is a host that quietly stays on the JPEG poll. The list is the set phyai01 carries (Ubuntu 24.04, GStreamer 1.24.2) — the host the stream was developed and verified on — where all of webrtcbin, x264enc, h264parse, rtph264pay, appsrc, videoconvert and nicesink resolve on the system python3.

    On a Jetson the encoder differs and the rest does not. g1-agx has L4T's nvv4l2h264enc and no nvh264enc — which is what the sender's nvenc mapping expects — and the distro GStreamer packages are already installed alongside the vendor stack there, with apt-get -s install gstreamer1.0-plugins-bad proposing no change whatsoever. Vendor encoder and distro plugins coexist; there is no need to hold one back for the other (measured 2026-09-11). - It is a sibling process, not part of vision. It runs GStreamer/webrtcbin and needs a GStreamer stack the ROS venv does not have, so it has its own interpreter (STREAM_PYTHON) and talks to vision over two loopback ports. stream_start / stream_stop are MCP tools on :9230 that hand off to it; if the sender is unreachable those tools answer {ok: false, message} rather than failing, and the brain stays on the get_frame poll. - STREAM_ENABLE needs VISION_ENABLE, which is also what it defaults to. The tools live on the vision service, so a stream sender with no vision window has nothing exposing it — and that is why the default is derived from that flag rather than being a flat 1. - A default you did not write behaves differently from one you did. Leave STREAM_ENABLE unset and the stream is best-effort: r1ctl install builds the sender's venv only on a host whose python can actually reach GStreamer (import gi, webrtcbin present), up turns the flag back off before the vision window starts if no sender is installed — so the robot never advertises stream_start it cannot serve — and a sender that fails to bind is a warning, not a failed up. Write STREAM_ENABLE=1 in r1.env and every one of those becomes fatal and named instead, which is what you want on a robot whose job is the stream. STREAM_ENABLE=0 turns it off entirely; the Visual view falls back to the JPEG poll it has always had. - It is viewer-gated and shares one encode. Nothing encodes until a browser negotiates, and every viewer is served off a tee from a single encoder — six viewers measured ~27 % of one core. That is what makes the brain able to offer the stream to spectators and not just the operator holding the control lease. - It says how many frames it is losing, and where (BIN-586). Every 5 s the vision window logs stream: 29.5 fps received, 29.3 fps forwarded, 0 dropped in 5.0s (0 dropped total). Read the two rates against each other, because they mean different things: received below the camera's publish rate means the frames were shed by the middleware before the sender saw them, and dropped above zero means the sender's own queue shed them. Only the second is anything the stream can fix; the first is a QoS or DDS problem upstream. Without this the loss shows up nowhere at all — it is a silent queue discard, and the operator symptom for both is the same word, "jerky".

r1.env var Default Meaning
STREAM_ENABLE $VISION_ENABLE launch the sender at all — derived, so it is on wherever vision is (since 2026-09-11). Needs VISION_ENABLE=1. Writing 1 yourself is stricter than leaving it unset: see below.
STREAM_CONTROL_PORT 9231 loopback only — how vision asks it to negotiate.
STREAM_FRAME_PORT 9232 loopback only — how frames reach it from the vision node.
STREAM_BITRATE (service default) target encoder bitrate. The only quality knob; resolution is not one — the caps arrive with the ROS stream.
STREAM_ENCODER (auto) which encoder element to use (hardware on the Orin, software elsewhere).
STREAM_PYTHON (auto) the interpreter with a usable GStreamer, since the ROS venv is not it.

\"The stream is smooth but slow\" is a different fault from \"the stream stutters\"

The frame count and the frame spacing fail separately, and the log line above only speaks to the first. A count below the camera's rate is the QoS/DDS class of problem; correct-count-but-uneven-arrival is a pacing problem, because the sender timestamps frames when they arrive (do_timestamp) rather than from a clock, so bursty arrival becomes bursty presentation that no downstream buffer can straighten. Check the camera's own published rate first: a rate claim about the viewer is worth nothing until you know what the camera actually put on the topic.

No STUN, no TURN — but \"on the tailnet\" is not the same as \"direct\"

Both peers negotiate with iceServers: [], so the browser and the robot must both be on the tailnet with real tailscale0 interfaces — that is why the simulator hosts (rap-0 / rap-1, which run --tun=userspace-networking) are not the place to test it and g1-agx is, and why a viewer off the tailnet simply gets the get_frame poll instead. STUN and TURN stay out because the tailnet already routes both peers; there is no address for STUN to discover.

What that does not buy you is a direct path. Tailscale attempts NAT traversal and silently relays through a DERP node when it fails, which nothing in the application can see. Measured 2026-09-10 (BIN-576): the sender held a flat 30.0 fps with zero drops while the operator watched the same stream freeze, relayed via DERP in Helsinki at ~50 ms. Ask tailscale ping <peer> which you have — direct connection not established is the relayed case — and read any "the stream is smooth" claim as being about the path it was measured on. A LAN measurement is a regression check on the source, not an acceptance test. Retransmission (nack + RTX, BIN-576) is what makes the relayed case watchable; without it a single lost packet costs everything up to the next IDR, a full second at idrinterval=fps.

A viewer that never granted the microphone is the awkward case

Browsers hide their host ICE candidates behind random <uuid>.local mDNS names until the page holds media-capture permission, and an unresolvable .local name means an empty check list — fatal on the Orin's webrtcbin 1.20.3. The brain therefore passes each viewer's address as viewer_ip and the sender appends a candidate at it. It is a hint: wrong costs one failed candidate pair, absent costs the same, and the browser falls back to the poll on its own. Nothing to configure robot-side; it is here so the failure is recognisable.

Verified on the central host: tailscale serve forwards the viewer's tailnet address in X-Forwarded-For, so a spectator reached over the production HTTPS URL does get a usable hint. A viewer on plain HTTP over the tailnet does too, from its own source address.

The segmenter fallback chain (BIN-136)

Masks are only worth having on a GPU, so the service resolves an ordered chain at startup and reports which tier won. Everything defaults off, so a plain VISION_ENABLE=1 gives box polygons and costs nothing extra.

# Tier cost_class Enabled by
1 local SAM on a GPU — ultralytics in-process fixed VISION_SAM_CHECKPOINT + the [sam] extra + genuinely usable CUDA
2 self-hosted SAM 3 — the free LangSAM box fixed SAM3_SERVER_URL answering GET /health; VISION_SAM3_SERVER_URL overrides
3 Modal SAM 3segmentation-modal metered VISION_MODAL_SAM3_URL and VISION_MODAL_ENABLE=1paid
4 box polygonsNullSegmenter fixed always; objects still enumerate and stay pickable

cost_class answers one question: does calling this again cost more money? metered bills per call; fixed runs on hardware already paid for. It said local/cloud until BIN-405, which named the wrong axis — tier 2 is a box at a remote IP and was still local, because the field never meant location. The brain uses the same two words for its LLM roles, so one cost table now covers models and vision alike.

There are no compatibility aliases. A brain reading local/cloud off a robot that has not been redeployed logs cost_class_unrecognised and shows the tier as unknown — it will not guess, because guessing fixed shows a billing Modal GPU as the free self-hosted box. Redeploy binabik-r1-vision (and the binabik-scene-perception it pins) together.

capture_scene reports segmenter_cost_class, segmenter_tier and segmenter_detail alongside segmenter; the segmenter_info tool on :9230 returns the same plus skipped[] — one line per tier that was passed over and why. That is how "why do my masks look like boxes" is answered without reading a log.

get_frame carries the same report as segmenter_info (so it costs nothing to ask), which is how the brain's UI surfaces it: a segmenter chip whose tooltip lists skipped[] verbatim, a box-fallback notice, and — while the paid tier is warm — a top-bar cost warning with an off button. See Using the brain.

Two rules the chain enforces, because both were silent failures before:

  • It never drifts onto CPU SAM (~12–15 s per capture, slower than the box fallback is worth). An unusable GPU skips the local tier, logs the reason and the matching-wheel fix; running CPU SAM anyway needs VISION_SAM_ALLOW_CPU=1 said out loud.
  • It never reaches Modal by accident. The paid tier needs the opt-in and a URL, and is deliberately not health-probed — any request to a scaled-to-zero Modal app cold-starts a billed GPU, so even checking would cost money. r1ctl up warns while it is enabled.

Install the local tier with uv pip install -e '~/rap/binabik-r1-vision[sam]'. A checkpoint set without the extra is safe: the chain logs it and falls through.

rap-1's VISION_SAM_DEVICE=cpu no longer works (BIN-122)

rap-1's installed torch wheel refuses the host's NVIDIA driver (CUDA initialization: driver … too old (found version 12050) ⇒ CUDA 12.5), so VISION_SAM_CHECKPOINT=…/sam2.1_t.pt with VISION_SAM_DEVICE=cpu used to fall back to CPU SAM. The chain refuses that and drops to box polygons instead of spending 12–15 s per capture. In order of preference:

  1. Fix the GPU. Install a matching torch build in the vision venv (the A30 has ~22 GB free); the service prints this exact command when it skips the tier:

    cd ~/rap/binabik-r1-vision
    uv pip install --python ./.venv/bin/python \
        torch torchvision --index-url https://download.pytorch.org/whl/cu121
    ./.venv/bin/python -c "import torch; print('cuda ok:', torch.cuda.is_available())"
    

    Then drop VISION_SAM_DEVICE (or set cuda) and restart the vision window; sam2.1_b.pt becomes viable. Watch VRAM against Gazebo's camera rendering.

  2. Use tier 2 — the free self-hosted SAM 3 box (SAM3_SERVER_URL) is very likely the better answer on rap-1 while the wheel is wrong.

  3. Keep CPU deliberately with VISION_SAM_ALLOW_CPU=1. The service then says on every startup that captures will take 12–15 s.

Timing and the per-call budget

A deployed brain runs call_timeout_s: 180gen_mcp_config.py writes that into every instance's generated mcp.yaml, overriding the 30 in robot-voice-chat's checked-in backend/config/tools/mcp.yaml (that value is the repo/dev default and is not what runs). capture_scene reports no MCP progress, so for it 180 s is a hard ceiling rather than an idle one — but the tighter limit in practice is the adapter's own R1_CALL_TIMEOUT_S (120 s). Measured warm on rap-1 with CPU sam2.1_t.pt: ~12–15 s (12 objects, contour polygons), mostly the Gemini grounding call rather than SAM. A GPU tier should land at ~1–2 s — so the budget is comfortable, and a capture that does time out means a stuck upstream, not a slow segmenter.

The local model is pre-warmed on a daemon thread at startup (BIN-124) — ultralytics otherwise only reads the checkpoint on its first predict, which made the first capture_scene after a restart take 45–85 s and time out while the second click worked. get_frame reports segmenter_warm.state (coldwarmingready/failed), so a slow first capture is distinguishable from a broken one. Remote tiers are never pre-warmed: warming Modal would cold-start a billed GPU on every restart. A warm-up failure only logs.

The vision window logs the chain and the warm-up together:

[r1_vision] segmenter chain — skipping local_gpu: CUDA NOT usable — CUDA initialization:
            The NVIDIA driver on your system is too old (found version 12050) …
[r1_vision] Install a torch build that matches the host driver (BIN-122): …/cu121
[r1_vision] segmenter: self_hosted — self-hosted SAM3 at http://cr-l40:8001
            [cost_class=fixed]

Cost note. The grounder runs on every capture, so cost is the same whichever tier serves masks — which is why the brain bounds its automatic re-captures (a couple of retries on an empty scene, then it waits for an explicit Re-segment; BIN-139).

Two standing gotchas:

  • Do not pin numpy<2 here. The vision venv is --system-site-packages but carries its own numpy 2.x, which shadows the ROS python's 1.26 — and the service runs fine on it (it never imports cv_bridge). Forcing <2 would downgrade a working service.
  • Rollback is a config flag, not a reinstall: clear VISION_SAM_CHECKPOINT (or set VISION_SEGMENTER_ORDER=box) and restart the vision window for instant box polygons.

The marker servo (binabik-docking, :9250) — opt-in

Parking precisely in front of a machine. navigate_to_named gets the robot to the right part of the room; dock_to_marker closes the last metre onto an AprilTag bolted to the machine, to about 2 cm (measured 11.0 / 20.0 / 10.7 mm square-on in the sim, through the real detector). Use it for any step that has to land on a shaft, a tray or a charger.

It is the one taught thing in this stack that survives a restart: a waypoint is a coordinate in the map frame and SLAM rebuilds map at every bring-up (see above), while a dock is defined relative to a physical marker that does not move when the map is.

DOCKING_ENABLE=1 VISION_ENABLE=1 ./r1ctl install   # the flags must be set for the INSTALL too
DOCKING_ENABLE=1 VISION_ENABLE=1 ./r1ctl up

r1-abstraction re-exports its five tools on :9220, so the brain and the L3 skill both call the contract and neither learns this address.

Variable Default Meaning
DOCKING_ENABLE 0 opt-in because it moves the base, not because it costs money
DOCKING_ROBOT r1 r1 | g2 | null. Try null first on any new dock — it runs the whole loop against a base that never moves, and a frame-convention mistake is much cheaper to find before the wheels turn
DOCKING_MARKER_TOPIC /marker/pose published at camera rate by binabik-r1-vision (KOE-33)
DOCKING_PYTHON (the backend's) the ROS interpreter its --system-site-packages venv is built on, exactly as VISION_PYTHON
DOCKING_PORT / DOCKING_HOST 9250 / 0.0.0.0 the brain reads this port from another host (BIN-344)

Everything else — speed caps, the approach budget, the 0.4 s dead-man, the two staleness thresholds — is read from the environment by the service itself and documented in binabik-docking's own README. r1ctl does not mediate them.

DOCKING_ENABLE=1 without VISION_ENABLE=1 looks like a broken detector

The marker pose comes from :9230. Without it the docking service starts perfectly, binds :9250 and reports healthy — and then every approach spends its full 15 s acquisition budget and aborts tag_never_seen, which reads as a detector or a lighting problem rather than as a flag nobody set. up prints a warning when the two flags disagree.

A warning rather than a refusal, because DOCKING_MARKER_TOPIC is configurable and a ground-truth publisher is how the servo is exercised on a host with no vision credential.

The dead-man dies with the process, and plat_free_port uses kill -9

The servo carries a 0.4 s dead-man that zeroes the base when commands stop arriving — but it is a thread inside the service, so a SIGKILL takes it along. up's reset and down free ports through plat_free_port, which does not make that distinction, so resetting a stack whose robot is mid-approach leaves the base holding its last command.

Prefer SIGTERM and a moment's wait when freeing :9250 by hand. Gazebo's VelocityControl holds the last command indefinitely; a physical base with its own stop-if-not-spoken-to timeout is the only real protection, and the R1's behaviour here is unmeasured.

Installed with the [servo] extra — a plain install leaves no server

mcp is an extra of binabik-docking rather than a base dependency: the repo keeps its MCP tool surface inside a 100% coverage gate that rclpy could not survive, so the SDK is not pulled in by default. r1ctl installs .[servo], and _installed probes the built console script rather than the venv directory, because a venv built without the extra has the package, no binabik-docking-servo, and would otherwise read as installed.

Ambient perception and episodic recall — retired (BIN-306)

Neither service exists on the robot any more, and r1ctl cannot start either. mcp-perception-buffer (:9202, PERCEPTION_ENABLE) and ros2-memory (:9203, EPISODIC_ENABLE) were retired in BIN-306: the flags, windows, ports and log files are gone from r1ctl, and a leftover PERCEPTION_ENABLE=1 in an old r1.env now does nothing at all.

"What do you see" is the world state (:9240, the world window, on by default). One snapshot, fetched when a reasoner needs it, stating its own staleness — see the world-state spec. There is no temporal recall and no scene history: nothing replaces what_did_you_see / get_scene_seconds_ago.

Why they went, in one line each: the buffer's scene probe called a VLM every 4 seconds, the cadence the standing cost rule keeps switched off, so it lived at PERCEPTION_ENABLE=0 — and BIN-299 caught its stale snapshot silently re-targeting a grasp in flight. The recorder was the history tier beneath it, answering questions no live surface asked, while writing ~1 GB/hour to a 20 GB volume (BIN-175 — the outage is kept above, under the ~/rap warning, because the lesson outlives the service).

Both specs were deleted with the services; git log --diff-filter=D -- docs/binabik-software/archive/ has them if a design is worth re-reading.


Smoke tests — prove the MCP path

These are the reason this page exists: two scripts that verify the robot-side path with no brain in the picture. Both run from the adapter's venv, which already has ToolClient. If you got here from the setup page, run them after step 3.

1. The adapter directly — read-only

Proves the adapter's tools reach galaxea and return the expected shapes.

cd ~/rap/r1-abstraction
uv run python - <<'PY'
import asyncio, json
from robot_mcp_kit import ToolClient

ADAPTER = "http://127.0.0.1:9220/sse"

async def main():
    c = ToolClient(ADAPTER, timeout_s=30)

    # 1) The stateless-primitive surface the L3 grasp FSM drives.
    tools = sorted(t["name"] for t in await c.list_tools())
    print("TOOLS:", tools)
    for need in ("perceive", "compute_grasp_joints", "plan_execute", "approach",
                 "tilt_for_depth", "attach_object", "turn_by", "verify_held", "stop"):
        print(f"  {'ok ' if need in tools else 'MISSING'} {need}")

    # 2) Perception (safe, read-only): does `perceive` return object data through the adapter?
    print("\nperceive:", json.dumps(await c.call_tool("perceive",
                                     {"prompt": "a can", "phase": "coarse"})))

    # 3) Idle safety call (no motion in flight → a no-op that reports cleanly).
    print("stop:", json.dumps(await c.call_tool("stop", {})))

asyncio.run(main())
PY

What "good" looks like:

  • TOOLS lists the stateless primitives — nothing in the "need" list prints MISSING.
  • perceive{"ok": true, "pose": {"x":…,"y":…,"z":…}, "dims": {"w":…,"d":…,"h":…}, "face_normal": …, "object_id": …} when a can is in view (or {"ok": false} if not — which still proves the round-trip). This is the shape L3 threads into compute_grasp_joints. A no cloud / timeout reason instead → When perceive fails.
  • stop{"ok": true, "stopped": true, "goals_cancelled": 0} while idle; goals_cancelled is only non-zero mid-motion.

What this catches: any primitive whose real shape differs from the grasp-service spec §2 — loudly, at that one primitive. Threading the pose on into compute_grasp_jointsplan_execute by hand is error-prone, so the next test does it through grasp(), where the L3 FSM drives the primitives.

2. A full grasp() — this moves the robot

Safety

Test 1 is read-only. This one executes a real grasp: the arm(s) and base move. Before running it on hardware — clear the workspace, keep the e-stop within reach, stand clear of the reach envelope, and start with a single lightweight object. stop() cancels the in-flight MoveIt/Nav 2 goal; know how to call it (test 1) before you start.

cd ~/rap/r1-abstraction
uv run python - <<'PY'
import asyncio, json
from robot_mcp_kit import ToolClient
async def main():
    c = ToolClient("http://127.0.0.1:9210/sse", timeout_s=300)  # a grasp can take minutes
    print(json.dumps(await c.call_tool("grasp", {"object": "coke can"}), indent=2))
asyncio.run(main())
PY

What "good" looks like:

  • {"ok": true, "held": true, "side": …, "approach": "side|top|two_hand", "attempts": N, …} when it picks the can up. ok == held is authoritative.
  • On an honest failure, {"ok": false, "reason": …}not_found, unreachable, no_strategy, unsupported_approach, tool_error, <state>_failed, missed, timeout, or, when the recovery tier gives up, its own reason (budget, deadline, give_up, no_action, or model-supplied text). That is the skill reporting cleanly so the brain can replan, not a crash. A <state>_failed reason names the exact FSM state / primitive; pair it with the r1_manipulation logs to see what the underlying tool returned.
  • Internally this drives the whole chain — detect_* → … → verify_*_held — with the recovery LLM on a miss when GRASP_POLICY=tiered (which may re-drive a step or switch chain). GRASP_SELF_IMPROVE=on additionally lets it learn which approach works per place.

To drive the same path from natural language, open the brain UI and say "pick up the coke can": a good plan shows a single grasp step (not a grasp_pose+pick loop), and with a destination it continues navigate_to_named → locate_surface → go_to_stance → place. See Using the brain.


Rebuilding a robot from scratch

The 2026-07-28 fleet rebuild, written down. ~/rap is a mounted overlay that can be wiped; ~/colcon_ws is not in it and survives.

Two different jobs, and only one of them is this section. A reset — which happens 1–2× a week — wipes the overlay and keeps ~/rap, so nothing needs installing; bootstrap/reconnect.sh gets the network and a shell back and you carry on. What follows assumes ROS, Gazebo, uv and tmux are already on the host and only the repos need restoring.

A genuinely empty container starts one layer lower — bootstrap/rebuild.sh (BIN-483)

If there is no ROS, no Gazebo, no uv, no tmux and no tailscale, the steps below have nothing to stand on. binabik-r1-host's bootstrap/rebuild.sh installs exactly the host prerequisites r1ctl assumes and then hands over to r1ctl install + r1ctl up:

bash ~/rap/binabik-r1-host/bootstrap/rebuild.sh

It delegates the whole tailscale / sshd / authorized_keys / /etc/hosts half to reconnect.sh rather than reimplementing it — four fixes landed inside that script (BIN-463/466/468/471), every one of them a failure that looked fine, and a second copy would be the one that misses the fifth. It also installs the two packages ros-jazzy-desktop omits and nothing else supplies: rosbridge-suite (without it up fails the whole stack on the missing :9090) and trac-ik-kinematics-plugin (without it six of seven planning groups have no IK solver, and the symptom is grasp-service IK found no solution, which reads like a grasp-tuning problem — why rosdep cannot supply it). Since BIN-470 install_sim_deps.sh ensures that second one too, so a host built any other way is covered as well; rebuild.sh still installs it here, because it runs before any of that and a host missing it should never get as far as a failing grasp. It writes no r1.env and no credential — r1ctl install seeds the former from r1.env.example, and secrets belong to the store. Full breakdown in the repo's own README.

1. Back up first — four things live only on the robot

cd ~ && mkdir -p rebuild-backup
cp ~/rap/binabik-r1-host/r1.env          rebuild-backup/          # every ENABLE flag + keys
cp ~/rap/galaxea_agent/.env              rebuild-backup/          # the Gemini key lives HERE
cp ~/rap/binabik-r1-vision/sam2.1_*.pt   rebuild-backup/ 2>/dev/null   # SAM checkpoints
cd ~/rap && git -C galaxea_isaac_moveit status --porcelain        # untracked local edits?

That last check is not paranoia: rap-0 carried a hand-made r1pro_perception/launch/perception.launch.py.rap that exists nowhere in git. Anything status reports as untracked or modified is about to be destroyed — copy it out.

~/colcon_ws survives the wipe, and that is a hazard

Wiping ~/rap leaves the built workspace behind, so stale install/r1pro_* persists. A rebuild that partially fails then leaves a mixed workspace which can silently run old code. Always clear the sim packages when rebuilding:

rm -rf ~/colcon_ws/build/r1pro_* ~/colcon_ws/install/r1pro_*

2. The order matters

git clone git@github.com:binabik-ai/binabik-r1-host.git ~/rap/binabik-r1-host
cp ~/rebuild-backup/r1.env ~/rap/binabik-r1-host/r1.env      # BEFORE install — see below
cd ~/rap/binabik-r1-host && ./r1ctl install                  # VISION_ENABLE=1 in r1.env ⇒
                                                             #   the vision stack too (BIN-143)
cp ~/rebuild-backup/.env ~/rap/galaxea_agent/.env            # after galaxea exists
cd ~/rap/binabik-r1-host && ./r1ctl up

Why that order and not any other:

Step If you get it wrong
r1.env before install a fresh clone starts from r1.env.example, so VISION_ENABLE is commented out — and install only clones an optional repo whose flag is already set. Vision then silently never launches (the next up does re-run install once the flag is in r1.env, but only then).
galaxea_agent/.env after install the directory doesn't exist until install clones it, and r1ctl env returns early rather than creating it.

install builds the colcon workspace itself, with the setuptools<80 pin that BIN-144 added — no separate colcon build step, and no --symlink-install dance. Rebuilding by hand after a partial failure still means clearing ~/colcon_ws/{build,install}/r1pro_* first: a mixed workspace runs stale code with no error.

3. The robot needs its own GitHub credential

r1ctl install clones five private repos over SSH and uv pulls the private robot-mcp-kit as a git dependency, so the robot itself must be able to authenticate to the binabik-ai org. Two failure modes seen in the same rebuild:

  • rap-1 had no SSH key at all — destroyed by an earlier overlay wipe, along with uv. It could not clone anything, so every deploy became a surgical scp from a workstation.
  • rap-0's key is passphrase-protected, which is precisely why its non-interactive git fails: install runs unattended, with no TTY to type a passphrase into.

So an unattended r1ctl install needs a passphraseless deploy key (or a pre-loaded ssh-agent in the same session). A robot with no credential cannot self-install at all — plan on scp for that host, and treat restoring the key as part of the rebuild.


Rollback

The robot-side stack is stateless — nothing lives only on the robot (the overlay is wiped on reset) — so rolling back is stopping the stack and/or redeploying a known-good commit.

  • Read exactly what a window ran: every tmux window's body is written to $R1_CMD_DIR/r1_<window>.cmd.sh (default /tmp) and the pane is sent one bash <file> line, so cat /tmp/r1_adapter.cmd.sh shows the launch line even after the window died — and the file is executable, so you can edit it and re-run it by hand. Each body also sets set -m and traps HUP TERM INT to kill its process group, so closing a window takes down what it started. A side effect worth knowing when reading tmux list-panes: every pane's current command is bash, the wrapper — not the server.
  • Stop the MCP stack: ./r1ctl down — kills the r1 session and frees every binabik port, :9230 included (BIN-177). It leaves the sim (robot session) up; stop that separately with bash sim/gazebo_sim.sh --kill, which reaps the gz sim server too — tmux kill-session alone leaks it (BIN-227).
  • Redeploy a known-good commit: check the repos back to a good SHA, then reinstall + relaunch (up resets first, killing any old run):
    git -C ~/rap/r1-abstraction checkout <sha>
    git -C ~/rap/grasp-service   checkout <sha>
    ./r1ctl install && ./r1ctl up
    
    Note galaxea_agent, grasp-service and r1-abstraction run tuned working trees on the robots (branch rap-1-local-<date>) — coordinate before overwriting them.
  • Drop recovery/learning without a full rollback: set GRASP_POLICY=deterministic in r1.env, then ./r1ctl up. grasp-service then needs no Anthropic key and won't call the recovery LLM or write lessons.
  • Turn the scene service off: drop VISION_ENABLE=1 and r1ctl up (then up's reset frees the port). The adapter falls back to image-only look, so nothing errors — the click-to-pick overlay just goes away.
  • Detaching a brain is a brain-host operation → Running a brain.

Troubleshooting

First-bring-up failures (colcon workspace, missing camera, a wrong --robot) are in the setup page's troubleshooting. This table is the operational set.

Symptom Cause / fix
A tool raises or returns a shape you don't expect The adapter's delegation drifted from the galaxea server — note the tool + its raw result and diff r1-abstraction/src/r1_abstraction/config.py's DELEGATION.
locate_3d / perceive → nothing found, every time Nothing in the camera's view, or R1_PERC_URL points at a different perception server. Also check the camera actually publishes (ros2 topic hz /head_camera/image) — see the sim settings.
Everything connects but nothing moves Usually the sim with no scene, or the adapter targeting servers other than the running ones. Confirm R1_MANIP_URL / R1_PERC_URL / R1_NAV_URL.
HTTP 421 "Invalid Host header" DNS-rebinding guard — only if a proxy rewrites Host; the servers relax it for tailnet use.
grasp hangs for minutes Expected on hard grasps (the recovery tier is patient); it bails cleanly at GRASP_DEADLINE_S (480 s). A single wedged primitive trips GRASP_CALL_TIMEOUT_S (120 s) and is terminal.
Error executing tool grasp: No module named 'anthropic' The pre-BIN-156 stack: GRASP_POLICY=tiered with the SDK missing, so the recovery tier killed every grasp. Fixed — r1ctl install now installs it. On an old checkout, cd ~/rap/grasp-service && git pull && uv sync.
grasp log line says policy=tiered(no-recovery), or startup said RECOVERY TIER DISABLED Working as designed: tiered with an unusable recovery backend runs static-only instead of failing. The startup line names the missing half — the SDK (r1ctl install) or the key (ANTHROPIC_API_KEY). Details.
A grasp fails with reason: "recovery backend unavailable: …" Recovery was wired but couldn't run when it was needed — the key went missing after launch, or the API rejected the call. The grasp itself was genuinely attempted; the reason says why it wasn't rescued.
r1ctl up exits non-zero: the galaxea MCP servers are not listening on … The bring-up genuinely failed. In order: tail -30 /tmp/r1_galaxea_mcp.log, tmux attach -t =r1 (the nav2-mcp/perception-mcp/r1-manip-mcp/rosbridge windows), tmux ls for a prefix collision, r1ctl install for missing galaxea venvs, tmux attach -t =robot for the sim.
up ends in WARN: optional galaxea MCP port(s) not listening: 8005 Nothing in binabik calls :8005 — the stack is usable and the exit code is unaffected. Why.
r1ctl up refuses: tmux session name collision Another tmux session's name starts with $SESSION (r1up, r1deploy, …) and tmux resolves targets by prefix. Rename it, pick another SESSION, or run r1ctl outside tmux — details.
r1ctl up came back without the scene service (:9230) VISION_ENABLE defaults to 0 and is not persisted, so a plain up silently drops a running :9230. Restart with VISION_ENABLE=1 ./r1ctl up, or put VISION_ENABLE=1 in r1.envdetails.
VISION_ENABLE=1 dies with the vision stack is incomplete: … The flag was not set when install ran, so neither vision repo was cloned. VISION_ENABLE=1 ./r1ctl install (or put it in r1.env) — how. The message names the missing piece.
:9230 won't bind after a restart Was a real bug until BIN-177: 9230 was in none of the port lists, so nothing freed a vision service that outlived its window. up's reset and down both free it now — on an older r1ctl, fuser -k 9230/tcp.
The first overlay click after a vision restart is slow The SAM checkpoint is loading. Pre-warmed at startup (BIN-124), so this only bites in the first ~minute; get_frame's segmenter_warm.state says warming until ready.
Masks look like rectangles even though VISION_SAM_CHECKPOINT is set The chain skipped the local tier. Hover the brain's segmenter chip (or call segmenter_info on :9230) — skipped[] names the reason, usually an unusable GPU (BIN-122) or a missing [sam] extra. It will not fall back to CPU SAM on its own.
capture_scene reports segmenter_cost_class: "metered" The paid Modal tier is serving masks — someone set VISION_MODAL_ENABLE=1. The brain's top bar shows a red cost banner with a Turn off button while the GPU is warm; otherwise it scales to zero after scaledown_window (180 s idle), or stop it now with segmentation-modal's segmenter_stop tool / POST /stop. (A robot predating BIN-405 says "cloud", which the brain does not map — it logs cost_class_unrecognised and shows the tier as unknown until you redeploy the vision service.)
Sim runs, robot spawns, no camera frames No X display or no GPU. sim/gazebo_sim.sh warns (X display ':0' not reachable) and continues — then every perceive fails with ERROR: No image. The sim settings.
rap-1 unreachable — ssh and :9220/:9210 all time out, node still active with rx 0 Usually a wedged tailscaled, not a dead robot. See Recovering an unreachable rap-1 — the robot-side stack is normally still running and needs no restart.
rap-1 unreachable and the node reads offline (or a second rap-1-1 appeared) It was reset, not wedged — tailscale is gone with the overlay, so there is no route in and nothing to un-wedge. Console only: Recovering rap-1 after a reset.
reconnect.sh finishes all green, ssh ros@rap-1 still says Permission denied (publickey) authorized_keys was on the wiped overlay and there is no copy in ~/rap/.ssh/ to restore from — or the file is not mode 600, which sshd ignores silently. The one-time fix.
perceiveno cloud (seg status: SEGMENTING), or fine estimate timed out Segmentation never produced a cloud. See When perceive fails; the usual cause is a sam_server_url this host cannot route to.
r1ctl prints nothing at all and exits non-zero r1.env failed while being sourced — it is read before r1ctl prints a word, under set -e. Current versions name the file, line and command; the classic cause is a $( ) substitution (details). Confirm with R1_ENV=/dev/null ./r1ctl status.
Six of seven servers up, :9090 (rosbridge) never opens, ModuleNotFoundError: No module named 'yaml' The pane's python3 is conda's, not the distro's — and conda deactivate in your shell does not change it. The preflight and the fix.
The adapter/grasp window dies with error while attempting to bind on address ('0.0.0.0', 9220) A tailscale serve proxy already owns the port (errno 48/98). up normally detects this and binds loopback instead — why, and what to do when it misses.
install ends in a galaxea failure, or pip says Neither 'setup.py' nor 'pyproject.toml' found A galaxea submodule at the right commit with an empty working treegit submodule update --init is a no-op against it. install force-checks-out now; details.
install printed SIM_PKGS_DIR (…) not found — skipping sim requirements An older r1ctl: galaxea's installer defaulted to a path from the machine it was written on, so open3d/scipy/google-genai were silently skipped. Pull binabik-r1-host and re-run install.
Every trajectory is accepted and nothing moves, with /clock advancing and MoveIt up No arm/gripper controller is loaded — on a fresh Linux host, almost always rosdep having resolved nothing. The rosdep box (BIN-231).
nav2 stays inactive, or every goal is refused with "Action server is inactive" A costmap plugin that cannot load, or bt_navigator failing on activate with a /opt/ros/… behaviour-tree path. nav2.launch.py repairs both at launch time now, on both platforms (BIN-235).

A brain reporting ready is not a robot-liveness signal

The brain's per-server booleans report only that it holds a session object, not that the server still answers (BIN-109). When a robot disappears without a clean TCP close — relay drop, host powered off, container killed — the session lingers and the probe keeps saying tools_connected: true for a robot that is completely gone. To test the robot itself, probe it from the brain host:

curl -s -o /dev/null -w "%{http_code}\n" --max-time 6 http://rap-1:9220/sse   # 200 = serving
tailscale ping -c 2 rap-1                                                     # pong = tailnet path OK

When perceive fails: the segmentation prerequisites

perceive is the gate for every grasp — no cloud, no OBB, no IK — and it depends on a remote segmentation backend. Until BIN-137 every way it could go wrong produced the same two unactionable lines:

perceive(prompt="the box", phase="coarse") -> {"ok": false, "reason": "[coarse_seg] no cloud (seg status: SEGMENTING)"}
perceive(prompt="the box", phase="fine")   -> {"ok": false, "reason": "fine estimate timed out"}

Both now name what was waited on and which dependency to check. The prerequisites, and how each one fails:

Prerequisite Where it comes from Failure signature
A reachable SAM 3 server sam_server_url, defaulting to $SAM3_SERVER_URL launch banner: !! SAM3 server … did NOT answer /health; then seg status: ERROR: connection / ERROR: timeout
GEMINI_API_KEY (for seg_backend gemini/hybrid) the secret store, else galaxea_agent/.env — resolved by the node and by perception.launch.py, never exported by r1ctl (BIN-439) gemini: the launch refuses to start; hybrid: a loud warning, SAM3-only
A camera actually publishing the sim's gz→ROS bridge (needs DISPLAY) seg status: ERROR: No image
Wrist channels, for phase="fine" enable_wrist_cameras:=true (default false) the fine message says so explicitly

SAM3_SERVER_URL, and why its fallback is unroutable from rap-1

If SAM3_SERVER_URL is unset, perception.launch.py falls back to a hardcoded 100.x lab address — and rap-1's Tailscale is userspace-networking (no tun), so it has no route to tailnet IPs and such traffic is dropped rather than refused. The segmentation POST then hangs for its whole budget instead of failing fast, and every perceive() fails with no cloud.

Both halves are pushed, and there are now two independent paths from r1.env to the perception node (BIN-151) — the setting works on a clean checkout:

  • binabik-r1-host: r1ctl resolves the value from the environment then galaxea_agent/.env, writes it back to both files and exports it; sim/gazebo_sim.sh tmux setenvs it into the sim session before the windows exist (galaxea's .env is sourced only by the MCP servers, and the sim runs in a different session) and launches perception with sam_server_url:=$SAM3_SERVER_URL when the variable is set.
  • galaxea_isaac_moveit: perception.launch.py defaults sam_server_url from $SAM3_SERVER_URL (BIN-137, and on the unified main since BIN-151), logs which source it came from, and probes /health, warning loudly and naming the URL when it doesn't answer.

Both still warn only, never fatally. rap-1's tuned trees were checked against origin/jazzy when BIN-151 landed (that tip is now frozen/jazzy): nothing lives only there.

Set it to a server that host can reach:

grep SAM3_SERVER_URL ~/rap/galaxea_agent/.env
curl -sS -o /dev/null -w '%{http_code} %{time_total}s\n' "$SAM3_SERVER_URL/health"

Whoever times out first decides the error message

Segmentation is asynchronous: the perception node waits segment_timeout_s (15 s) for a cloud, the SAM 3 node waits sam_server_timeout for its POST. Those were 30 s vs 15 s, so the consumer always gave up first with SEGMENTING as the last status it had seen — which is why an unreachable backend and a merely slow one looked identical. The SAM 3 budget is now the shorter one — sam_server_timeout 12 s against segment_timeout_s 15 s — so it loses patience first and publishes a terminal status naming the server, and tools/check_perception_timeouts.sh fails CI if the two ever invert again. (Both are on main, the unified tree; the pre-collapse frozen/humble tip keeps its own perception stack.)

OPENAI_API_BASE is the VLM, not the grounder

A dead :8000 is a real problem but not this one: OPENAI_API_BASE serves image Q&A and the grasp/place held-checks (send_image_to_vlm, _verify_*_held, place_object_on_target). Nothing on the box-pose path touches it — grounding is SAM 3 or the Gemini API. Expect held-check failures from a dead :8000, not perceive failures.

not_found when the object is right there — the base actually turns now (BIN-195)

Before you debug the segmenter, check whether the object was ever in frame. A perceive that answers NO_OBJECTS_FOUND is usually telling the truth, and it says so in the perception window:

[segmentation_gemini_head]: Gemini text response: … zero objects that can be clearly
                            identified as a toolbox in the original image
[segmentation_gemini_head]: Mask for "toolbox" is all-black after thresholding
[r1pro_perception_node]: [seg:head_gemini] segmenter found no objects matching 'toolbox' …
[coarse_seg] no cloud … (seg status: NO_OBJECTS_FOUND) — segmentation ran fine and found
             nothing matching the prompt — this is a naming/visibility problem, not a broken
             dependency; try a different word or move the camera.

Repeated at one settled pose with the object in view, perceive is not flaky: six consecutive calls returned the same box with dims stable inside 1 cm, and nonsense prompts (a banana, a purple elephant) correctly failed. So a run of not_found means look somewhere else, and until BIN-195 the R1 could not:

  • grasp-service's R1 pack had the sweep switched offLocateSpec.survey_tool="", on the stale comment "the R1 has no base-rotation primitive". _locate therefore took its no-survey branch and re-perceived the same frame GRASP_SURVEY_TURNS + 1 times, ~8 s apart, then returned not_found. Four identical misses in ~33 s, for an object one turn away.
  • and turn_by would not have helped, because it dead-reckoned: /cmd_vel carried the requested 0.40 rad/s while the Gazebo base turned at 0.11–0.16, so a 45° request moved 12–20° and still returned success: true. A three-step "sweep" would have covered ~12°.

Both are fixed together — the jogs are closed-loop on odom and report achieved (the contract rule), and the pack sweeps. What to expect now: a failed locate rotates the robot up to GRASP_SURVEY_TURNS × GRASP_SURVEY_DEGREES (135° at the defaults) before giving up, and each 45° step takes ~8 s rather than ~2 s, because arriving at a third of the commanded velocity honestly takes longer. A not-found grasp is therefore slower than it was and moves the base — if that is unwanted in a given cell, set GRASP_SURVEY_TURNS=0.

That sweep moves the base — and until BIN-294 nothing checked what it moved into

The survey turn, the recovery tier's jogs and the Visual jog pad all go through turn_by / drive_by / strafe_by, which published straight to /cmd_vel. Closed-loop on odom told them how far they had gone, never what was in the way, and nav 2's collision_monitor was absent on the real robot and configured as a 1 cm no-op in the sim — so an unattended sweep could drive the robot into something.

Since BIN-294 turn_by and drive_by run as nav 2 behaviours (spin_robot, drive_on_heading, backup_robot), which check the local costmap. Same tool names, same arguments, and achieved is still measured — the adapter reads the base pose before and after, because the behaviours do not report displacement themselves. What to expect on the robot:

  • A jog that would hit something now fails rather than driving. It does not fall back to the unchecked path — read error, and collision_checked tells you which path you got.
  • strafe_by is still unchecked: nav 2's behaviour server does spin and along-heading motions only, so a holonomic sideways move has no equivalent. It reports collision_checked: false with the reason.
  • R1_UNCHECKED_BASE_JOG=1 puts everything back on the raw path — for a robot with no nav 2 running, not for convenience.
  • A second, lower gate now exists (BIN-294 part 2). collision_monitor sits on the one topic every base command converges on: /cmd_vel → monitor → /cmd_vel_guarded → the chassis relay (real robot) or the Gazebo bridge (sim). So it catches publishers that never touch the adapter at all, including a raw jog. Placement is the whole trick — the stock nav 2 arrangement sits upstream of /cmd_vel and would never see them. Disable with collision_monitor:=false, which also points the relay back at /cmd_vel.
  • A monitor that is running is not necessarily watching. The sim's shipped with a 1 cm stop polygon its own comment called "effectively a no-op pass-through" — visible in ros2 node list, protecting nothing. If you are checking whether the guard is live, read its polygons, not the node list.
  • The costmap sees the chassis lidar only. The head cameras are RGB-only, so a table edge or an overhang is invisible to it (BIN-298): a checked jog is checked against a world that omits exactly the obstacles a manipulation cell is full of. Do not treat this as "the robot avoids obstacles now".

stop also cancels the nav 2 goal now, not just the manipulation-side goals — a stopped base that still held a navigation action would resume the moment the velocity stop lifted.

The base tracks ~a third of the commanded /cmd_vel on the sim

Sustained 0.40 rad/s yields ~0.16; 0.15 yields ~0.041 — roughly proportional, with ~40% run-to-run spread, so no static calibration factor helps and closed-loop is the only thing that holds. Whether the Gazebo omni-base controller should track better is a separate sim-fidelity question in galaxea_isaac_moveit, deliberately not chased here; the closed loop absorbs it either way and works unchanged on a base that tracks properly.

Recovering rap-1 after a reset

A reset is not the wedged-daemon case below and not a rebuild either. rap-1 is a container reset roughly once or twice a week; ~/rap is a separate volume and survives, so the repos, their venvs and the real r1.env / .env files with their API keys are all still there. What goes is the overlay: tailscale, sshd's runtime state, /etc/hosts, uv, tmux, ~/.ssh.

Signature, distinguishing it from the wedge below: the node is offline in tailscale status (not active with rx 0), and a new node has registered — the old rap-1 still holds the name, so the returning one appears as rap-1-1.

One script does the network half: binabik-r1-host/bootstrap/reconnect.sh. Run it at the console — a reset that wipes tailscale removes the only route in, so you cannot scp the script that installs tailscale. It copies itself to ~/rap/reconnect.sh on success, so the next reset already has it; before its first successful run, use the committed copy:

bash ~/rap/binabik-r1-host/bootstrap/reconnect.sh

The console is noVNC at https://rap-1.robopaas.dev, password-gated and slow — paste one compound command at a time.

Two files under ~/rap are what make this a one-liner, both there because that volume survives while ~/.ssh does not:

File Without it
~/rap/.ts-authkey no tailnet. Must be a reusable key — a single-use one works exactly once and then fails looking like a network fault
~/rap/.ssh/authorized_keys no inbound ssh. The script restores it into ~/.ssh at mode 600 and, if you paste a key at the console instead, captures it here on the way out so the next reset is unattended (BIN-463)

Set the second one up once, from your laptop, and every future reset is covered:

cat ~/.ssh/id_ed25519.pub | ssh ros@rap-1 'mkdir -p ~/rap/.ssh && cat >> ~/rap/.ssh/authorized_keys'

A running sshd is not the same as being able to log in

These are two different keys under ~/.ssh and neither implies the other: id_ed25519 is outbound (r1ctl cloning the private repos), authorized_keys is inbound (whether ssh lets you in at all). Until BIN-463 the script started sshd, verified pgrep -x sshd, printed every check green — and ssh still refused the key, because authorized_keys went with the overlay. It now checks the file: present, non-empty, and mode 600, because sshd silently ignores a group- or world-writable authorized_keys, which is indistinguishable from having no key.

Two manual steps on your Mac, every reset: delete or rename the stale node in the Tailscale admin console (else the returning robot is rap-1-1), and ssh-keygen -R rap-1, since sshd's host keys live on the overlay and regenerate.

Then, over sshreconnect.sh deliberately does none of this and prints the list on exit: uv, tmux, the outbound git key, ros-jazzy-rosbridge-suite (not part of ros-jazzy-desktop, and nothing in the stack installs it — without it :9090 never opens and r1ctl fails everything), and the r1pro_* colcon build (the image ships a workspace for a different robot, so skipping it makes every sim window say Package 'r1pro_sim' not found). Then r1ctl install && r1ctl up as above. ROS 2 Jazzy itself is on the image at /opt/ros/jazzy — it only looks missing because command -v ros2 fails in an unsourced shell.

That list is exactly what bootstrap/rebuild.sh automates, so on a host where more than the network is gone, run it instead of working through the items by hand — it calls reconnect.sh itself, so it is safe to reach for either way.

Flags, env vars and the full step list live in binabik-r1-host's own README, which owns them.

Recovering an unreachable rap-1 (wedged tailscaled)

Signature. From the brain host, ssh ros@rap-1 and both :9220/:9210 time out — by hostname and by IP, with MagicDNS resolving correctly — while tailscale status still lists rap-1 as active but with rx 0 (we send, nothing ever comes back) and tailscale ping rap-1 gets no reply. Meanwhile, on the robot itself, both tmux sessions (r1, robot) are still running fine.

Two things make this easy to misdiagnose:

  • ping proves nothing. rap-1's Tailscale runs --tun=userspace-networking, so it never answers ICMP even when perfectly healthy. Use tailscale ping (disco, works in userspace mode).
  • The brain will still say ready — see the readyz warning above.

Cause. The long-lived tailscaled process wedges: it stops serving its own localapi socket (the CLI then reports dial unix /var/run/tailscale/tailscaled.sock: connect: connection refused) and survives SIGTERM, so pkill tailscaled does nothing while it keeps holding the SOCKS5 port. Any replacement daemon dies immediately on SOCKS5 listener: bind: address already in use / safesocket.Listen: address already in use, leaving a stale socket file behind.

Fix. rap-1 has no systemd (it is a container — sshd and tailscaled are started by hand), so recovery is manual, over the RAP web console. That console is slow, so paste one compound command at a time.

If only sshd died (tailnet fine, ssh refused):

sudo mkdir -p /run/sshd && sudo /usr/sbin/sshd && echo SSHD_UP

For the wedged daemon — SIGKILL it, drop the stale socket, and relaunch with its original flags, read out of /proc so nothing is lost (rap-1's include --outbound-http-proxy-listen= besides --tun=userspace-networking --socks5-server=localhost:1055; the SOCKS5 proxy is what gives the robot its only outbound route to the brain):

PID=$(pgrep -f '^tailscaled' | head -1); CMD=$(sudo tr '\0' ' ' < /proc/$PID/cmdline); echo "OLD: $CMD"; sudo kill -9 $PID; sleep 2; sudo rm -f /var/run/tailscale/tailscaled.sock; (sudo sh -c "$CMD" >/tmp/ts.log 2>&1 &); sleep 6; sudo tailscale status | head -3; tailscale ip -4

Check the printed IP — rap-1's tailnet IP has changed across rebuilds; if it moved, flush your Mac's DNS and ssh-keygen -R rap-1. If tailscale up instead prints a login URL, the node key expired: approve it, then disable key expiry for the robots in the admin console.

Then verify from the brain host — and expect to restart nothing on the robot:

tailscale ping -c 2 rap-1
for p in 9220 9210 9230; do curl -s -o /dev/null -w "$p %{http_code}\n" --max-time 6 http://rap-1:$p/sse; done

The sim, MoveIt, Nav 2 and the MCP servers all survive a tailnet outage untouched (the camera keeps publishing throughout — ~2.3 Hz on /head_camera/image), because only the inbound tailnet path was broken. What does need attention is the brain: its MCP sessions to the robot broke mid-stream, so either let the first robot command burn one silent reconnect-and-retry, or restart the brain container for clean sessions — which also resets the cooperative control lease, so click Take control in the UI afterwards (Running a brain).