Set up your own robot + sim, and get a brain¶
The simulator is per person. The brain is already running — you ask for an instance, you do not build one.
Read this first: what already exists, and what you actually set up
Already running, centrally — do not rebuild it:
- The brain host is the 4090 (
ssh 4090, userbot). The image is built, instances are running, andbrain-pascalserves the installable PWA at https://phyai4090.tailc7b34f.ts.net/. - The shared central memory (
brain-memory,:9103) that every brain attaches to.
What you set up, on your own machine:
- The Gazebo simulator (or a real robot).
- The robot-side MCP servers —
r1ctl upbrings up both.
How you get a brain: you get an instance on the 4090, pointed at your machine
over the tailnet. That is one brainctl up on that host — a few seconds, no build,
no Docker on your side. See step 2.
Building your own brain host is the exception, not the path. It is an appendix for when you genuinely have no tailnet route to the 4090, or you are working on the brain image itself.
What you are building¶
┌──────────── your machine / VM ─────────────┐ ┌──── the 4090 (exists) ────┐
│ │ │ │
│ grasp-service :9210 │◀───────│ brain-<you> :80NN │
│ r1-abstraction :9220 │ MCP/ │ (one container, │
│ │ │ SSE │ your robot) │
│ ▼ over the │ over │ │ │
│ galaxea_agent :8004 :8003 :8001 │ tailnet│ ▼ │
│ │ (+ rosbridge :9090) │ │ brain-memory :9103 │
│ ▼ │ │ (shared) │
│ ROS 2 Jazzy ──▶ Gazebo Harmonic sim │ └───────────────────────────┘
└────────────────────────────────────────────┘
Everything in the left box is yours and comes up with one command (r1ctl up).
Everything in the right box already exists; you just need a container in it. The rest of
this page is prerequisites and verification.
Read this before you provision the VM — cameras need a real GPU
Gazebo's camera sensors render on the GPU through an X display. A headless VM, or one with no GPU passthrough, will run the simulator perfectly happily — physics, MoveIt, Nav 2, the robot spawns — and publish no camera frames at all. Nothing fails loudly; you just get an empty scene from every perception call.
So: give the VM a GPU and an X server on :0, or accept that you are testing
everything except perception and grasping. This one decision determines whether the
setup is useful to you. See platform notes.
0. Prerequisites¶
The machine. Ubuntu 24.04 with ROS 2 Jazzy — the sim is Gazebo Harmonic on
Jazzy, and the install scripts assume that pairing (apt package names, pip
--break-system-packages, Python 3.12). x86-64 is the tested architecture; see
platform notes for Apple silicon and WSL2.
Access and tools:
| Need | Why | Check |
|---|---|---|
SSH access to the private github.com/binabik-ai org |
r1ctl clones four private repos (more with the opt-in services), and uv pulls the private robot-mcp-kit as a git dependency |
ssh -T git@github.com |
uv |
every Python service uses it | curl -LsSf https://astral.sh/uv/install.sh \| sh |
| Tailscale | required — your brain instance lives on the 4090 and reaches your robot servers over the tailnet | tailscale status |
tmux, psmisc (fuser), iproute2 (ss), x11-utils (xdpyinfo) |
r1ctl uses all four; without fuser stale ports are never freed, without ss the port checks silently pass |
command -v tmux fuser ss xdpyinfo |
sudo |
one-time apt/rosdep steps during the sim build | — |
| Docker (only for the appendix) | not needed at all for the normal path — the brain runs on the 4090 | docker run --rm hello-world |
Keys. Have these ready before you start:
GEMINI_API_KEY— the robot-side perception grounding. Without it the sim's perception nodes come up but cannot ground a scene, andhybridsegmentation degrades to plain SAM 3.r1ctldoes not ask for it or store it (BIN-439): put it in the secret store this robot's identity reaches, or — with no store — ingalaxea_agent/.env, the file the MCP windows and the perception node's environment come from. Each service resolves it for itself.GROQ_API_KEY/ANTHROPIC_API_KEY— the brain's keys. These live inbrain.envon the 4090 and are already configured there; you need them only if you build your own brain host (appendix). An instance can instead fetch them per-instance from a key store — see Per-instance secrets.MEMORY_TOKEN— the bearer token for the central memory (:9103), and the only credential that must be byte-identical on two machines: the robot pushes lessons with it and the memory container validates them, so the robot's copy and the brain host's copy are one value in two places. Get it from the brain host (brain.env) rather than inventing one; a fresh value on the robot alone does not fail loudly, it 401s every push and fleet learning simply stops (BIN-430). It is optional in the sense that memory auth can be off — but if it is set anywhere, it must be set the same everywhere.
Check it rather than trusting it, and without ever printing a credential — compare fingerprints on the two hosts:
# on the robot, and again on the brain host with brain.env
v=$(grep -E '^MEMORY_TOKEN=' r1.env | cut -d= -f2- | tr -d '"'"'"' \r')
printf '%s len=%s\n' "$(printf %s "$v" | sha256sum | cut -c1-12)" "${#v}"
Two different fingerprints is the bug, and it is invisible any other way. This is the general habit for any shared secret: compare a hash, never the value.
- A reachable SAM 3 segmentation endpoint (
SAM3_SERVER_URL) — see the honest limitation below.
You can put these in a secret store instead of a file (BIN-426)
Everything above assumes hand-edited .env files, which is still the supported path and
still the default. If you would rather not keep keys in files, give the robot an identity —
secrets/<ROBOT_ID>.env beside r1ctl, or r1ctl up --secrets-file <path> — holding a
bootstrap credential and BINABIK_SECRETS_BACKEND (google or infisical). Each service
then fetches its own keys at runtime.
It is genuinely optional and there is no flag day: a name the store does not hold keeps
whatever the .env gave it, so a stack can migrate one credential at a time. Details and
the "did it actually work" check: the robot runbook.
MEMORY_TOKEN is the credential the store helps most with, because it is the one value
that has to match on two hosts: with both sides reading the same name from one store, a
rotation stops being "edit two files on two machines and hope no third copy was missed".
It is seeded in binabik-dev as of 2026-09-01.
One caveat while BIN-439 finishes: r1ctl still writes MEMORY_TOKEN's literal value into
the grasp launch line, so that service reports it as 1 from environment even when the store
holds it. The store value is correct and simply not yet the path taken there — the brain host
already resolves entirely from store.
Without a reachable SAM 3 server, perceive() and grasp() will not work
The sim's perception stack posts to a SAM 3 server for segmentation. Its default
is a lab-internal address your VM cannot route to, and the failure mode is a hang
followed by every perceive() returning no cloud — so grasping never gets a point
cloud.
Your options: point the sim's perception at a SAM 3 server you can actually reach; or
deploy your own off-robot segmenter (segmentation-modal runs SAM 3 on Modal); or
accept a stack that navigates, moves the arms and streams camera frames but does not
perceive objects. Decide this up front — it is the second most common reason a
from-scratch setup feels broken.
Setting SAM3_SERVER_URL does take effect on a clean checkout (BIN-151): r1ctl
exports it, gazebo_sim.sh pushes it into the sim session and passes
sam_server_url:=… to the perception launch, and perception.launch.py defaults from the
variable and probes the server's /health at launch, warning loudly and naming the URL
when it doesn't answer. So a wrong or unreachable endpoint tells you at bring-up instead of
at the first grasp — see the R1 page.
1. The simulator and the robot-side servers¶
Both come from one launcher, r1ctl, in binabik-r1-host:
eval "$(ssh-agent -s)" && ssh-add ~/.ssh/id_ed25519 # r1ctl clones over SSH
git clone git@github.com:binabik-ai/binabik-r1-host.git ~/rap/binabik-r1-host
cd ~/rap/binabik-r1-host
./r1ctl env # writes r1.env from the committed example
On a fresh machine that first env call writes only r1.env — the second env file
(galaxea_agent/.env, which holds the VLM configuration and the BINABIK_SECRETS_FILE
pointer) can't be created until galaxea_agent has been cloned, so install creates it then.
Neither file gets a credential written into it by the launcher.
Now edit r1.env before you install — two settings are wrong by default on any
machine whose user is not ros:
# ~/rap/binabik-r1-host/r1.env
COLCON_WS=$HOME/colcon_ws # where the sim's ROS workspace is SOURCED from
WS=$HOME/colcon_ws # where it is BUILT — the same path (r1ctl mirrors one to the other)
SAM3_SERVER_URL=http://<your-sam3-host>:8001
The colcon workspace path is the single most common fresh-machine failure
The sim launcher defaults to /home/ros/colcon_ws — a lab path. On any other user
that directory does not exist, the workspace fails to source silently (the script
deliberately does not use set -e), and all four sim windows then die with
"package r1pro_sim not found".
And it is one workspace with two names: sim/gazebo_sim.sh reads COLCON_WS, while
galaxea_isaac_moveit/install_sim_deps.sh reads WS. Since BIN-149 r1ctl no longer lets them
disagree — set either and it sets the other, and set both to different paths and it exits,
naming which script reads which, because that state is unresolvable: the sim would be built in
one workspace and sourced from the other. Setting both to the same path, as above, is still the
clearest thing to write.
With neither set, each script keeps its own default — and if /home/ros/colcon_ws is not
creatable by you, up says so before anything long runs. The failure that replaced was
install reaching install_sim_deps.sh and dying on mkdir: cannot create directory
'/home/ros', forty lines into pip output.
Also check what python3 a tmux pane resolves — before you blame the install
If your ~/.bashrc auto-activates conda (or any other environment), every tmux pane gets that
interpreter, because a pane starts a fresh interactive bash and re-sources the rc file. ROS
Python nodes run #!/usr/bin/env python3, so rosbridge dies on No module named 'yaml' while
the other four galaxea servers — which run their venv's interpreter by path — come up fine. A
stack that reads 6/7 healthy for a cause outside all seven.
r1ctl up preflights this and stops with the remedy, which is not conda deactivate in your
own shell (the pane never sees it):
R1_ROS_PYTHON_CHECK=0 opts out. The full mechanism.
A line in r1.env that fails stops the launcher
r1.env is sourced before r1ctl prints a word, under set -euo pipefail. Current versions
name the file, the line and the command; older ones exited non-zero in total silence. Prefer
literal values here — a $( ) substitution's subshell inherits set -e, so one non-zero
command inside it kills the launcher. R1_ENV=/dev/null ./r1ctl status confirms the file is the
cause.
Then install and launch:
./r1ctl install # clones the repos, builds venvs, builds the sim workspace
# → writes galaxea_agent/.env (config + identity pointer)
./r1ctl up # launches the sim, then every MCP server
install can still report success when the sim workspace failed to build
A failed colcon build is deliberately non-fatal (a missing apt dependency is the usual cause —
it will tell you to sudo apt install libnlopt-cxx-dev), so install done. still prints and the
failure surfaces one step later, as up timing out on the MCP ports. Read install's output
rather than trusting its exit.
A failed galaxea install, by contrast, is now fatal: those are :8001/:8003/:8004, the
ports up calls required, so a cheerful install done. over them was only ever hiding the cause
(details). If pip says "Neither 'setup.py'
nor 'pyproject.toml' found", that is a galaxea submodule at the right commit with an empty
working tree — git submodule update --init is a no-op against it, and install force-checks
it out for you now.
rosdep is the dependency mechanism, and it no longer needs root
The sim's ROS dependencies come from the packages' own package.xml files, resolved by
rosdep install --from-paths. rosdep init writes under /etc and so wanted sudo, whose failure
used to be swallowed — after which rosdep resolved nothing while the build succeeded and the
sim started, giving a stack with /clock advancing, MoveIt up, and no controller loaded, so
every trajectory was accepted and nothing moved. r1ctl install now points rosdep at a
user-writable sources list, and the installer repeats its verdict at the end
(BIN-231). This is the one place the sudo in the prerequisites
above is no longer needed for resolution — only for the apt packages rosdep then installs.
install clones galaxea_agent, r1-abstraction, grasp-service and
galaxea_isaac_moveit on main, then builds the colcon workspace. The
optional services are cloned only when their flag is set at install time — with
VISION_ENABLE=1 that means binabik-r1-vision and binabik-scene-perception, in a
--system-site-packages venv so rclpy resolves (BIN-143). up
asks Launch the Gazebo sim now? [y/N] — answer y the first time. (Non-interactively,
set SIM_AUTOLAUNCH=1; otherwise the sim is skipped and the whole bring-up then fails at
the port check.)
SIM_AUTOLAUNCH=1 alone is not enough — a tty beats it
up checks whether stdin is a tty before it looks at SIM_AUTOLAUNCH, so an
interactive run asks the question regardless of the variable, and a run whose stdout is
piped blocks forever on a prompt you cannot see. For an unattended launch, redirect
stdin as well:
That does not cost you the controlling terminal, which tmux still needs — only the
tty on stdin, which is the thing being tested. And do not wrap it in
tmux -L <socket>: TMUX makes r1ctl's own tmux calls land on that socket, so r1
and robot are created there and r1ctl status, r1ctl down and tmux attach -t r1
all report an empty stack.
main is the branch — and the repo name lies
Despite the name, this is the Gazebo sim, not Isaac. main is the unified tree:
one branch for the sim and the robot since BIN-276, selecting per host by $ROS_DISTRO
and backend:=. r1ctl checks it out for you (GALAXEA_ISAAC_BRANCH, default main).
It used to be jazzy, because main was then an old Ignition Fortress / ROS 2
Humble port running ign gazebo — a command that does not exist on Jazzy, so the
server never started and there was no camera. That port is gone, and so is jazzy: it
was merged into main and deleted upstream, so a git checkout jazzy copied from
an older runbook now fails with a missing-ref error (BIN-478). The pre-collapse tips are
kept frozen as frozen/humble and frozen/jazzy for reference and take no commits; do
not build from them.
Rebuild the r1pro_* colcon packages after any branch switch — a stale install/
keeps running the old code, which is the part of the old warning that is still true.
If you ever switch branches by hand, rebuild the workspace or the stale install/
keeps running the old code.
What you should have now. Two tmux sessions:
| Session | Windows | What |
|---|---|---|
robot |
monitor, sim, moveit, nav2, perception |
Gazebo + MoveIt + Nav 2 + the perception nodes. monitor is created first so DISPLAY, BINABIK_SECRETS_FILE and SAM3_SERVER_URL can be pushed into the session before the others exist. |
r1 |
rosbridge, nav2-mcp, perception-mcp, r1-manip-mcp, ros-mcp, adapter, grasp |
rosbridge + galaxea's MCP servers + r1-abstraction (:9220) + grasp-service (:9210) |
r1ctl up waits for the MCP ports and exits non-zero if the required ones
(9090 8001 8003 8004) never opened, so a silent half-failure is not possible. :8005
(ros-mcp) is optional and only warns. Check any window with
./r1ctl logs <window>, or ./r1ctl status for the port summary.
The full r1ctl surface — every subcommand, env var, port and window — is in
the R1 robot stack.
2. Your brain instance — on the 4090, not on your machine¶
The brain host already exists and the image is already built. You need one container in it, pointed at your machine. Nothing is installed on your side.
What you provide: your machine's tailnet hostname — the brain reaches your
:9220 and :9210 over Tailscale, so join the tailnet first:
tailscale up
tailscale status | head -1 # note YOUR machine's tailnet name
ss -ltn | grep -E ':(9220|9210)' # both should show 0.0.0.0
Bound to 127.0.0.1 instead of 0.0.0.0, a remote brain cannot connect — that is the most
common reason an instance comes up with no tools.
One legitimate exception: a tailscale serve proxy in front of them
If you put tailscale serve --bg --tcp 9220 tcp://127.0.0.1:9220 in front of a service (the
workaround for a host-level firewall), r1ctl up binds that service to 127.0.0.1 on
purpose and says so as it launches — because 0.0.0.0 over a proxy that already holds the
tailnet addresses is EADDRINUSE and the server dies instantly. The brain still connects to
<host>:9220, through the proxy. So read the pair together: loopback plus a proxy is
correct; loopback alone is the failure above. The whole
story.
Then ask for an instance (or run it yourself if you have access to the 4090):
ssh 4090
cd ~/sage/orchestrator/brain-host
./brainctl up <you> --robot <your-tailnet-host> --port <free port ≥ 8001>
./brainctl ls # prints the HTTPS URL to open
That is it — a few seconds, because robots/r1.yaml is the built-in default manifest and
the image is already there. Open the https://…ts.net URL ls prints: it is a secure
context, so the full UI works and it installs as a PWA.
Don't disturb the instances that are already running
brain-pascal on :8001 is the primary and owns the PWA root — leave it alone, give your
instance its own --port, and test changes on a scratch instance rather than recreating
someone else's. Why, and the exact commands, are
Running a brain → launch an instance.
Everything else an instance can do — a different planner model, Hydra overrides, extra MCP servers, memory scoping, the HTTPS root, updating it later — is Running a brain.
3. Verify, bottom-up¶
Check the layers in order — the first one that fails is the one to fix, and a failure higher up is almost always a symptom of a lower layer.
1. The sim is stepping and the cameras publish.
tmux attach -t robot # window 'sim' — Gazebo, robot spawned
ros2 topic hz /head_camera/image # ~2 Hz. SILENCE = no GPU/display (see above)
2. The MCP servers listen.
./r1ctl status # SERVICE / PORT / STATE — every required port present
ss -ltn | grep -E ':(9220|9210)' # both on 0.0.0.0 (or loopback + a serve proxy, above)
3. The adapter answers, and perception round-trips.
curl -s -o /dev/null -w '%{http_code}\n' --max-time 6 http://127.0.0.1:9220/sse # 200
./r1ctl logs adapter
4. Your brain instance attached to your robot — run this on the 4090, against your instance's port:
Expect status: ready, tools_connected: true, and r1-abstraction + grasp true
in mcp_servers. If they are false, the brain cannot reach your machine: check that
up printed (<your-host> → 100.x.y.z) rather than a resolve warning, that your servers
are bound to 0.0.0.0, and that tailscale ping <your-host> answers from the 4090. Note
the route is /api/readyz — /api/admin/readyz returns the SPA's HTML with a 200 and
reads as a false pass.
5. Drive it. Open the URL brainctl ls prints, type "what do you see" or "pick up
the coke can", and open the Debug panel to watch it plan and call tools. What the UI
can do — the Visual Command view, the live sub-module telemetry, teaching — is in
Using the brain.
Platform notes¶
| Platform | Verdict |
|---|---|
Ubuntu 24.04, x86-64, NVIDIA GPU, Xorg on :0 |
The reference setup. Everything on this page works, cameras included. |
| Apple silicon (macOS, native) | The sim runs well, on a different ROS install (RoboStack, no VM) but through the same r1ctl commands — including the robot-side MCP servers, so a brain can drive it over the tailnet. What it cannot do is depth, so no grasp perception. Run the brain elsewhere. Running the simulator → macOS. |
| A Linux VM on Apple silicon | Not recommended: the sim's Python dependencies include x86-64-only wheels, so the workspace build fails on arm64 Linux. Use the native macOS path. |
| WSL2 | Camera rendering depends on the WSL GPU/WSLg stack and is not something we have verified. Expect to debug rendering. |
| Headless / no GPU | The stack comes up and the robot moves, but no camera frames — so no perception and no grasping. Fine for planner and navigation work, misleading for anything visual. |
Both simulator paths, with the capability matrix and every platform-specific gotcha, are in Running the simulator.
Appendix — running a brain on your own machine¶
You probably don't need this. Use it only when you genuinely have no tailnet route to the 4090, you are working offline, or you are changing the brain image itself. Otherwise get an instance — it is faster and it is what everyone else runs.
The brain is a Docker container built from four repos:
mkdir -p ~/binabik-src && cd ~/binabik-src
git clone git@github.com:binabik-ai/robot-voice-chat.git
git clone git@github.com:binabik-ai/robot-mcp-kit.git
git clone git@github.com:binabik-ai/robot-mcp-memory.git
git clone git@github.com:binabik-ai/binabik-brain-host.git
Then set BRAIN_SRC to that directory, write a brain.env, and build the image — the host
prerequisites and both of those steps are
Running a brain → prerequisites. The only part that is specific
to sharing one machine with the robot is what you pass as --robot, next.
What to pass as --robot when the brain and the robot share a host¶
This is the part that catches everyone. The brain runs in a bridge-network container, so it
does not share the host's network namespace. --robot must be a name or address that,
from inside the container, reaches your host's :9220 and :9210.
--robot localhost looks like it works and never does
getent resolves localhost to 127.0.0.1, so brainctl prints
(localhost → 127.0.0.1) with no warning — but inside the container that is the
container's own loopback, where nothing is listening. You get a brain that starts,
reports ready, and has zero robot tools. Same trap for 127.0.0.1 and for
--robot $(hostname), which Ubuntu usually maps to 127.0.1.1.
Pass an IP address of the host instead:
Either way up prints a WARNING: could not resolve …. Ignore it here — there is no
name to look up, which is correct for a literal IP; the container routes to it directly.
On one machine, keep the brain off the robot's ports
brainctl would default to 8001 — exactly where galaxea's nav2-mcp listens. Worse,
r1ctl up frees nine ports on every reset (9090 8001 8003 8004 8005 9220 9210 9240
9230), so it would kill the brain's published port too. Use something well clear of the robot
stack; this appendix uses 8100.
Two more conditions:
- The robot servers must be bound to
0.0.0.0, not127.0.0.1.r1-abstraction,grasp-serviceand the vision service already are; galaxea's own servers bind loopback, which is fine because only the adapter (running on the host) talks to them. - A third form,
--robot host.docker.internal, works but is fragile: that hostname is mapped into the container only as part of the central-memory wiring, so adding--no-memorysilently removes it and every robot URL fails to resolve. The two options above have no such coupling — prefer them.
When it doesn't work¶
| Symptom | Cause / fix |
|---|---|
All four ROS windows in robot die with "package r1pro_sim not found" |
The colcon workspace wasn't sourced — COLCON_WS/WS still point at /home/ros/colcon_ws. Set it in r1.env (either name; r1ctl mirrors it), then ./r1ctl install. |
r1ctl exits: "COLCON_WS and WS name DIFFERENT workspaces" |
Exactly what it says — the sim would be built in one and sourced from the other. Set one, or both to the same path (BIN-149). |
r1ctl prints nothing at all and returns non-zero |
A line in r1.env failed while it was being sourced. Current versions name the line; confirm with R1_ENV=/dev/null ./r1ctl status. |
Six of seven servers up but :9090 never opens, No module named 'yaml' |
The pane's python3 is conda's. conda config --set auto_activate_base false — not conda deactivate — why. |
The adapter/grasp window dies with bind on address ('0.0.0.0', 9220) |
A tailscale serve proxy owns the port. up normally detects it and binds loopback — details. |
install fails on galaxea with "Neither 'setup.py' nor 'pyproject.toml' found" |
A submodule at the right commit with an empty working tree; install force-checks-out now, so pull binabik-r1-host and re-run it. |
The sim comes up healthy but nothing moves — /clock advances, MoveIt is up, no controller loaded |
rosdep resolved nothing (historically a swallowed sudo rosdep init). Pull binabik-r1-host and re-run install; ROSDEP_STRICT=1 makes it fail loudly (BIN-231). |
| Sim runs, robot spawns, no camera frames | No X display or no GPU. r1ctl warns (X display ':0' not reachable) but continues. Give the VM a GPU + Xorg on :0. |
Every perceive() returns no cloud; grasps never start |
SAM3_SERVER_URL unreachable — the default is lab-internal. See the SAM 3 limitation. |
r1ctl up exits non-zero: "the galaxea MCP servers are not listening" |
The bring-up genuinely failed. Attach to r1 and read the failing window — usually the sim never came up (so move_group never appeared), or a venv is missing. |
r1ctl up refuses: "tmux session name collision" |
Another tmux session's name starts with r1 (r1up, r1-debug). tmux resolves targets by prefix, so galaxea's servers would land in the wrong session. Rename it, or run r1ctl outside tmux. |
uv sync fails on robot-mcp-kit |
Your git isn't authenticated to the private org. Load your SSH key. |
| Brain boots, but a role answers "this provider has no credential" | That provider's key is absent from both the key store and brain.env. The brain no longer exits over it (BIN-239) — pick a provider that has a key from the config panel, or set the missing one and recreate the instance. docker logs <container> 2>&1 \| grep credentials names it. |
Brain is ready but has no robot tools |
Almost always a wrong --robot — see the localhost trap. Confirm with curl /api/readyz, then check the (name → IP) line in the up output. |
| Brain chats, but every mission is refused naming a variable | The planner has no credential — by default ANTHROPIC_API_KEY. Since BIN-239 it does not silently fall back to Groq; set the key and recreate, or choose a planner with a key in the config panel. |
VISION_ENABLE=1 dies with "the vision stack is incomplete: …" |
The flag has to be set for the install too, and it is not persisted: VISION_ENABLE=1 ./r1ctl install (or put VISION_ENABLE=1 in r1.env, which also makes the next up self-heal). BIN-143. |
More R1-side detail — the smoke-test scripts, per-server env tables, and the lab-robot recovery procedures — is in the R1 robot stack.