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.r1ctlbrings up the sim, those galaxea servers, and both binabik layers — in two tmux sessions, from oner1.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, soinstallstarts fromr1.env.example— whereVISION_ENABLEis commented out. The vision service then silently never launches. (_bootstrap_envnever overwrites an existingr1.env; it is the re-clone that loses it.) Restore your backup first — see Rebuilding a robot. installclones the optional repos only if their flag is set at install time. SettingVISION_ENABLE=1afterwards self-heals —_installed()probes each service's venv (vision included since BIN-143), so the nextupre-runsinstalland fetches what is missing. That auto-install also re-runsinstall_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 withSIM_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/mcppath — e.g.http://127.0.0.1:8004/mcp. r1-abstraction(:9220),grasp-service(:9210) andbinabik-r1-vision(:9230) speak SSE at the/ssepath — 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.sh — platform/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:
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 stack — r1-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:
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_DIRis passed toinstall_mcp_local.sh. That script installs the sim packages'requirements.txt—open3d,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 printedSIM_PKGS_DIR (…) not found — skipping sim requirements.and quietly went without them.r1ctlknows where it cloned the sim, so it says so (exported, since the installer is a separate process; anr1.envoverride still wins).Exactly
requirements.txt, neverrequirements*.txt— the glob is what mader1ctl installfail on every run (BIN-472). It also matchedr1pro_perception/requirements.humble.txt, the robot's exact Jetson pins, whoseopen3d==0.18.0is a py3.10 wheel; rap-1 is Jazzy on py3.12, where none exists, so pip refused it. The abort landed before.venv-shimis created, and since the manipulation server was split into a ZMQ agent plus a ROS-free shim, no shim venv means no:8004at all — noplan_execute, nojoint_state, nosolve_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 plusrequirements.$ROS_DISTRO.txtwhere 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 interruptedclone --recurse-submodulesleaves — and nothing ordinary repairs it:git submodule update --init --recursivecompares 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--forcerewrites the files, andinstallruns it — on both the clone and the pull path — asking the question that matters: are the files there. Same correction as_installedprobing 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 —
— 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:
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 all —
VISION_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 rclpy — binabik-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:
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_FILE → secrets/$ROBOT_ID.env →
secrets/<short hostname>.env → nothing. 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:
- a service account for this robot alone (
robot-<id>@<project>.iam.gserviceaccount.com); - one conditional
secretAccessorbinding 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); - the identity itself: a key file at
~/.config/binabik/<robot>-sa.json(mode 600, directory 700), or aniam.serviceAccountTokenCreatorgrant whereconstraints/iam.disableServiceAccountKeyCreationblocks key creation; secrets/$ROBOT_ID.env— the file discovery already looks for — mode 600, written key by key so an existing file keeps its other settings;- the backend's SDK (
google-cloud-secret-manager) in the venvsr1ctlbuilds itself — the galaxea MCP venvs and vision's--system-site-packagesone, neither of which has a lockfile for it to be declared in. Auv 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 identityupcannot find. The command computes whether discovery would find it and says so.- A
GOOGLE_APPLICATION_CREDENTIALSnaming a key that was never minted is fatal inup, so the impersonation outcome writes no path and clears a stale one. For the same reasonprovisiondoes 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.shinstalls the kit from the sibling cloner1ctlmaintains (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.pypins both halves. - No credential is
tmux setenv'd at all. OnlyBINABIK_SECRETS_FILEis. Pushing the key put it in the tmux server's environment, where every window in the session inherits it andtmux showenvprints 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_TOKENis 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_TOKENon grasp's, andR1_VISION_MODAL_TOKEN—r1.env'sVISION_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
r1ctlpath to a key.r1ctl envused to prompt forGEMINI_API_KEYand write it into two files; since BIN-439 it writes none. Put the key in the file the consuming window sources —galaxea_agent/.envfor the MCP servers,grasp-service/.envfor the recovery tier — and the registry's chain resolves it from there.That covers the sim's perception node too since BIN-439:
r1ctlexportsBINABIK_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 fromgalaxea_agent/.env. Before that the sim session sourced no.envat all and a store was the only route.It fills gaps only, unlike every other window in the stack, where
set -a; . .envis right because a tmux window inherits nothing. This session is the exception: the launcher pushesDISPLAY,SAM3_SERVER_URLand the store identity into it, resolved for that run, and a plain source would let a staleSAM3_SERVER_URLin the file win — BIN-137's failure by a new route.Do not put the key in
r1.envexpecting it to reach the sim.r1ctlsourcesr1.envunderset -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 thatupstarts it the value propagates; if a server is already running it does not, andupreuses 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
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_moveitonmain—r1ctlchecks it out for you (GALAXEA_ISAAC_BRANCH, defaultmain). This entry used to say the opposite, and following it now means trying to check out a branch that no longer exists:jazzywas merged intomainand deleted upstream, withfrozen/jazzykept as the snapshot (why). After switching branches by hand, rebuild ther1pro_*colcon packages — a staleinstall/keeps running the old code, and that is the half of the old warning that is still true.rap-0andrap-1must run the same branch; a mismatch is exactly what broke rap-1's camera while rap-0 worked.DISPLAY=:0is required for camera rendering. Gazebo's camera sensors (and RViz) render on the GPU via the robot's Xorg on:0. A headlessgz simruns but its cameras produce no frames (RViz dies withno Qt platform plugin "xcb").sim/gazebo_sim.shpushesDISPLAYinto 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 bygz_ros_bridgeinsim.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:
Meshes in the sim workspace
Link meshes are package://r1pro_description/meshes/<name>.STL — always 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:
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:
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:
- The SDK installs.
anthropicis a plaingrasp-servicedependency (robot-mcp-kit[mcp,recovery]), declared at the site that imports it, sor1ctl installinstalls it.tieredis grasp-service's default policy; its dependency does not belong behind an extra. - A startup check, not a first-grasp surprise. A
tieredlaunch asks whether the backend is usable and, if not, logs once and doesn't wire the tier at all — the static path runs, exactly asdeterministicwould, and every grasp line readspolicy=tiered(no-recovery).r1ctl upwarns too. The message names which half is missing, because the fixes differ: theanthropicSDK is not installed →r1ctl install; no Anthropic key →ANTHROPIC_API_KEYingrasp-service/.envorr1.env. - Containment at escalation. Anything that still escapes the tier (a stale
robot-mcp-kiton the robot, an unreachable tool server during discovery) is logged, emitted as arecover_unavailableprogress 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:
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.
Config — r1.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 —
r1ctldoes not install it, and nothing else will. It is the one thing the stream needs that no installer here provides:_install_streambuilds.venv-streamwith--system-site-packageson an interpreter that must already have the bindings, andbinabik-r1-vision'spyproject.tomlcannot 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-giEvery one of those is a named element in
stream_pipeline.py, not a precaution: bad iswebrtcbinitself plush264parse, good isrtph264payand thertpsessionthat carries BIN-576's nack/RTX, base isappsrc/videoconvert, nice is the ICE that forms a candidate pair at all, and thegir1.2-*pair is the typelibs behind the sender'sgi.require_versioncalls.-uglyis the one people drop: it carriesx264enc, and the encoder auto-order isnvenc → 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.shinstalls the set (stage 2b) — on a host built by that script there is nothing to do here.On macOS:
brew install gstreamer pygobject3 libnice-gstreamer—libnice-gstreameris a separate formula, and without itwebrtcbinloads but every sink-pad request returnsNULL, 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
r1ctlasks itself before it installs a derived stream, so a host where this printsNoneis a host that quietly stays on the JPEG poll. The list is the setphyai01carries (Ubuntu 24.04, GStreamer 1.24.2) — the host the stream was developed and verified on — where all ofwebrtcbin,x264enc,h264parse,rtph264pay,appsrc,videoconvertandnicesinkresolve on the systempython3.On a Jetson the encoder differs and the rest does not.
g1-agxhas L4T'snvv4l2h264encand nonvh264enc— which is what the sender'snvencmapping expects — and the distro GStreamer packages are already installed alongside the vendor stack there, withapt-get -s install gstreamer1.0-plugins-badproposing 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 ofvision. It runs GStreamer/webrtcbinand needs a GStreamer stack the ROS venv does not have, so it has its own interpreter (STREAM_PYTHON) and talks tovisionover two loopback ports.stream_start/stream_stopare MCP tools on:9230that hand off to it; if the sender is unreachable those tools answer{ok: false, message}rather than failing, and the brain stays on theget_framepoll. -STREAM_ENABLEneedsVISION_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 flat1. - A default you did not write behaves differently from one you did. LeaveSTREAM_ENABLEunset and the stream is best-effort:r1ctl installbuilds the sender's venv only on a host whose python can actually reach GStreamer (import gi,webrtcbinpresent),upturns the flag back off before thevisionwindow starts if no sender is installed — so the robot never advertisesstream_startit cannot serve — and a sender that fails to bind is a warning, not a failedup. WriteSTREAM_ENABLE=1inr1.envand every one of those becomes fatal and named instead, which is what you want on a robot whose job is the stream.STREAM_ENABLE=0turns 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 ateefrom 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 thevisionwindow logsstream: 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:receivedbelow the camera's publish rate means the frames were shed by the middleware before the sender saw them, anddroppedabove 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 3 — segmentation-modal |
metered |
VISION_MODAL_SAM3_URL and VISION_MODAL_ENABLE=1 — paid |
| 4 | box polygons — NullSegmenter |
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=1said 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 upwarns 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:
-
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 setcuda) and restart thevisionwindow;sam2.1_b.ptbecomes viable. Watch VRAM against Gazebo's camera rendering. -
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. - 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: 180 — gen_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 (cold → warming → ready/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<2here. The vision venv is--system-site-packagesbut carries its own numpy 2.x, which shadows the ROS python's 1.26 — and the service runs fine on it (it never importscv_bridge). Forcing<2would downgrade a working service. - Rollback is a config flag, not a reinstall: clear
VISION_SAM_CHECKPOINT(or setVISION_SEGMENTER_ORDER=box) and restart thevisionwindow 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 intocompute_grasp_joints. Ano cloud/ timeout reason instead → Whenperceivefails.stop→{"ok": true, "stopped": true, "goals_cancelled": 0}while idle;goals_cancelledis 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_joints → plan_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 == heldis 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>_failedreason names the exact FSM state / primitive; pair it with ther1_manipulationlogs to see what the underlying tool returned. - Internally this drives the whole chain —
detect_* → … → verify_*_held— with the recovery LLM on a miss whenGRASP_POLICY=tiered(which may re-drive a step or switch chain).GRASP_SELF_IMPROVE=onadditionally 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:
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:
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 surgicalscpfrom a workstation. - rap-0's key is passphrase-protected, which is precisely why its non-interactive git
fails:
installruns 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 onebash <file>line, socat /tmp/r1_adapter.cmd.shshows 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 setsset -mand trapsHUP TERM INTto kill its process group, so closing a window takes down what it started. A side effect worth knowing when readingtmux list-panes: every pane's current command isbash, the wrapper — not the server. - Stop the MCP stack:
./r1ctl down— kills ther1session and frees every binabik port,:9230included (BIN-177). It leaves the sim (robotsession) up; stop that separately withbash sim/gazebo_sim.sh --kill, which reaps thegz simserver too —tmux kill-sessionalone leaks it (BIN-227). - Redeploy a known-good commit: check the repos back to a good SHA, then reinstall +
relaunch (
upresets first, killing any old run):Notegit -C ~/rap/r1-abstraction checkout <sha> git -C ~/rap/grasp-service checkout <sha> ./r1ctl install && ./r1ctl upgalaxea_agent,grasp-serviceandr1-abstractionrun tuned working trees on the robots (branchrap-1-local-<date>) — coordinate before overwriting them. - Drop recovery/learning without a full rollback: set
GRASP_POLICY=deterministicinr1.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=1andr1ctl up(thenup's reset frees the port). The adapter falls back to image-onlylook, 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.env — details. |
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. |
perceive → no 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 tree — git 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:
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:r1ctlresolves the value from the environment thengalaxea_agent/.env, writes it back to both files and exports it;sim/gazebo_sim.shtmux setenvs it into the sim session before the windows exist (galaxea's.envis sourced only by the MCP servers, and the sim runs in a different session) and launches perception withsam_server_url:=$SAM3_SERVER_URLwhen the variable is set.galaxea_isaac_moveit:perception.launch.pydefaultssam_server_urlfrom$SAM3_SERVER_URL(BIN-137, and on the unifiedmainsince 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:
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 off —LocateSpec.survey_tool="", on the stale comment "the R1 has no base-rotation primitive"._locatetherefore took its no-survey branch and re-perceived the same frameGRASP_SURVEY_TURNS + 1times, ~8 s apart, then returnednot_found. Four identical misses in ~33 s, for an object one turn away.- and
turn_bywould not have helped, because it dead-reckoned:/cmd_velcarried the requested0.40 rad/swhile the Gazebo base turned at0.11–0.16, so a 45° request moved 12–20° and still returnedsuccess: 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, andcollision_checkedtells you which path you got. strafe_byis still unchecked: nav 2's behaviour server does spin and along-heading motions only, so a holonomic sideways move has no equivalent. It reportscollision_checked: falsewith the reason.R1_UNCHECKED_BASE_JOG=1puts 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_monitorsits 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_veland would never see them. Disable withcollision_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:
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 ssh — reconnect.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:
pingproves nothing. rap-1's Tailscale runs--tun=userspace-networking, so it never answers ICMP even when perfectly healthy. Usetailscale ping(disco, works in userspace mode).- The brain will still say
ready— see thereadyzwarning 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):
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).