Multi-robot mesh¶
Robot(name, mesh=True) joins a Zenoh mesh - joining is opt-in, so a bare Robot() leaves robot.mesh as None unless STRANDS_MESH is true/1/yes. Peers then discover each other on the LAN and can query, command, and e-stop one another.
Device Connect is the recommended networking layer
What's described here is the built-in Zenoh mesh — the automatic fallback. When the device-connect extra is installed, Robot().run() and robot_mesh() use Device Connect (structured RPC, presence, registry, safety) and fall back to this mesh only when it's unavailable. Both ride on Zenoh.
On a fresh install the mesh refuses to start until you choose a security
posture: with no ACL configured, Robot(..., mesh=True) logs Mesh did NOT
start and leaves robot.mesh.alive as False. For localhost experiments set
the developer preset in every process that joins; the lab and production
postures (STRANDS_MESH_ACCEPT_PERMISSIVE_ACL, STRANDS_MESH_ACL_FILE) are on
the Security page.
# process A
from strands_robots import Robot
sim_a = Robot("so100", mesh=True)
print(sim_a.mesh.peers) # discovers sim_b within ~1 s
# process B
sim_b = Robot("aloha", mesh=True)
sim_a.mesh.tell(sim_b.mesh.peer_id, "pick up the cube",
policy_provider="mock", duration=10.0)
[mesh] requires eclipse-zenoh>=1.6.1. The safety handlers authenticate an
e-stop / resume publisher at the wire level, below the JSON body, using
zenoh.SourceInfo on the publisher and Sample.source_info on the receiver.
Both names first ship in 1.6.1; on an older zenoh neither exists, so envelopes
travel unattributed and a receiver refuses one published by a peer that is
attributing. Upgrade every peer in a fleet together.
Key mesh calls¶
# Point-to-point status query
result = sim_a.mesh.send(target_peer_id, {"action": "status"}, timeout=5.0)
# Fan-out → list of responses collected within timeout
results = sim_a.mesh.broadcast({"action": "status"}, timeout=2.0)
# Safety primitive - writes a tamper-evident audit log
sim_a.mesh.emergency_stop() # STRANDS_MESH_AUDIT_DIR overrides log location
What a fleet e-stop reaches¶
emergency_stop() stops the robot registered in the issuing process first, then
broadcasts {"action": "stop"} with no robot_name, so each peer decides which
of its own robots that reaches. The local stop is not an optimisation: a
broadcast never returns to its sender, so without it the robot the operator is
standing next to is the only one an e-stop never halts. Its answer leads the
returned responses list under this peer's own id and is graded like any other.
A hardware peer stops its task. A simulation peer asks every rollout it could be
running: the rollouts its backend reports as in flight where it keeps such a
registry (MuJoCo prunes finished ones), and otherwise every robot the engine
lists. stop_policy is idempotent and reports was_running itself, so asking
an idle robot costs nothing and the verdict is read rather than guessed -
stopped names only the robots whose answer did not say they were idle.
The peer's ok is derived from those per-robot answers, never assumed:
responses = sim_a.mesh.emergency_stop()
# {"ok": True, "stopped": ["arm"], "results": {...}} the rollout halted
# {"ok": True, "stopped": [], "results": {...}} asked, none was running
# {"ok": False, "stopped": [], "not_stopped": ["arm"], ...} a stop was refused
A refusal puts the peer in peers_not_stopped, which emergency_stop() logs at
CRITICAL and carries in the safety envelope. A backend that keeps no durable
per-robot rollout claim refuses, and that refusal is what you want: on the safety
path an acknowledgement that nothing was running is an affirmative answer given
on no evidence. Bound such a rollout instead of stopping it -
run_policy(n_steps=...) caps its length and run_policy(stop_when={...}) ends
it as soon as the world reaches a state.
Recovering from an emergency stop¶
emergency_stop() latches a lockout on every peer that receives it. While a
peer is locked out it refuses every command except status, resume and
stop; a second e-stop must still halt a rollout the first one missed, and a
stop only ever de-energizes. Nothing clears the lockout on a timer - an e-stop
that expired by itself would not be an e-stop. Recovery is always an explicit
resume:
Two prerequisites have to be in place before you e-stop a fleet, because both are only observable once you are already locked out.
1. Every peer needs the same override code. resume is accepted only when
STRANDS_MESH_OVERRIDE_CODE is set, and receivers re-verify the operator's proof
against their own copy. With no code configured there is no remote resume at all
and each robot must be restarted with one set - so the mesh logs a WARNING at
startup when it is unset. Set it to the same value on every peer.
2. Fleet clocks have to agree. A resume envelope is stamped with the
operator's wall clock, and a receiver refuses one that is stale or future-dated:
older than STRANDS_MESH_RESUME_FRESHNESS_S (default 60s) or more than
STRANDS_MESH_RESUME_FORWARD_SKEW_S (default 5s) ahead. Each bound catches one
direction of skew - a receiver ahead of the operator trips the freshness
window, a receiver behind it trips the forward bound - so widening the other
one does not help. The forward bound is the tight one, which is the trap: a robot
whose clock is only 6 seconds behind the operator sees a correct,
correctly-signed resume as future-dated and refuses it, logging
and every retry fails the same way, so the robot stays locked out until its clock is corrected or the bound is widened. Keep fleet clocks in NTP sync - the same "upgrade every peer together" discipline the zenoh floor needs above - or raise both knobs on every peer.
Correcting those clocks does not cost you the fleet. The bounds above are the
only place a stamp crosses a machine boundary. Everything the mesh decides from
a duration on its own - how old a peer's last heartbeat is (age), whether
that peer has timed out (10s), which peer the registry evicts when it hits
STRANDS_MESH_MAX_PEERS, and the sensor publish intervals - is measured on
time.monotonic(), which no NTP correction, date -s or resume from suspend can
move. So bringing a robot's clock into sync to satisfy the forward bound above
will not make the next heartbeat tick drop every peer it can still hear.
Nor are those durations the measured peer's to report. age is your
process's reading of when it last heard from a peer, and the peer_id a peer is
filed under is the one its topic and certificate bind - not a field inside the
payload. A presence payload is merged into what you read about a peer so you get
its capabilities (tool_name, connected, cameras, ...), and those five
locally decided keys - peer_id, type, hostname, age, reachable - win a
name collision with it. A peer heartbeating "age": 0 does not report itself
fresh, one claiming "reachable": true does not report itself in contact, and
one naming another peer's id does not answer a lookup for that peer.
The same rule decides a lockout badge. A safety envelope's t is the
sender's wall clock, admitted anywhere inside the two bounds above, so it may
sit a minute behind the clock of the process reading it. Anything a monitor ranks
against its own observations - when it first saw a peer, when it last watched
that peer accept a command - is therefore ordered against the instant it
learned of the stop, not against t. Lockout records both:
since is the reported instant, for display; arrived is the local one, and
every verdict is decided on it. Reading t instead let a peer that was already
on the mesh look freshly spawned ("may never have received it"), and let a
command accepted before the stop was even known count as proof the peer was
clear.
Repeated wrong codes arm a brute-force cooldown
(STRANDS_MESH_RESUME_MAX_FAILS, STRANDS_MESH_RESUME_BACKOFF_S): during the
cooldown even the correct code is refused, so wait it out rather than retrying in
a loop. Every attempt, granted or refused, is written to the safety audit log.
Published topics¶
| Topic | Rate | Content |
|---|---|---|
strands/{peer_id}/presence |
2 Hz | heartbeat / peer discovery |
strands/{peer_id}/state |
10 Hz | joints, sim time, task status, which robots are running a policy, degraded probes |
strands/{peer_id}/cmd |
on demand | incoming RPC commands |
strands/{requester}/response/{responder}/{turn_id} |
on demand | RPC replies (turn_id correlated) |
strands/{peer_id}/stream |
on demand | VLA execution steps |
strands/{peer_id}/pose |
on demand | SE(3) from SLAM/odom/VIO |
strands/{peer_id}/imu |
on demand | orientation, gyro, accel |
strands/{peer_id}/health |
on demand | battery, CPU, memory |
strands/broadcast |
on demand | fan-out RPC |
Sensor topics only publish when the robot exposes the attribute. Zero cost when unused.
A reply key is built from the request envelope, so its routing fields are
identifiers. A command carries sender_id (where to answer) and turn_id
(which turn is being answered), and the reply is published on
strands/{sender_id}/response/{responder}/{turn_id}. Both fields must match
[A-Za-z0-9_.-]+ and be at most 128 characters -- the same rule the teleop
identifiers follow -- because Zenoh routes a wildcard by intersection, so a
segment holding one would address the reply at every peer's
strands/{peer}/response/** subscription instead of at the peer that asked. A
command whose envelope breaks the rule is refused whole: nothing is dispatched,
nothing is published, and the refusal is written to the safety audit log.
Omitting sender_id is unchanged and still means fire-and-forget -- the command
runs and no reply is published.
A sensor record's identity is the publisher's too. A reader seeds each record
with what this process decided, merges the robot's provider mapping over it, and
publishes to a topic built from those same keys - so the same precedence applies:
peer_id, and the hand a hand record is filed under, win a name collision with
the provider mapping. A provider naming another peer does not move its readings
onto that peer's topic, and one naming another hand does not relabel the hand it
was published under. A t the provider supplies is honoured: it is a stamp
rather than a locally computed duration, so a driver that stamps a reading when
it decoded it reports something truer than the moment the loop published it.
Degraded state probes¶
Every section of a state snapshot is optional, because a robot may be hardware, sim, both or neither. So an absent section is ambiguous on its own: a robot with no joints and a robot whose joint read just failed publish the same thing.
A probe that fails therefore names itself, keyed by category, so the fault is on the wire rather than only in that peer's log:
{
"peer_id": "arm-a1",
"t": 1755900000.123,
"degraded": {
"hw_joints": {
"reason": "ConnectionError",
"detail": "Port is in use!",
"failures": 37,
"for_seconds": 3.7
}
}
}
reason is the exception's type name, which is what selects the next move: a
ConnectionError from a contended serial port is a different job from a
RuntimeError from an arm nobody calibrated, and both used to arrive as an
absent joints. detail is that exception's message, bounded because it comes
from a driver and the topic publishes ten times a second. failures counts the
ticks that have raised since the fault began and for_seconds how long it has
been failing, so one unlucky read is distinguishable from a standing fault.
The entry is removed on the tick the probe answers again, so the block always describes the current state rather than the worst thing that ever happened. The key is absent entirely when nothing is degraded.
It also keeps such a peer talking. A snapshot with nothing to report is not
published, so a hardware-only peer whose one section was joints used to go
silent on this topic for as long as its bus was contended -- while its presence
heartbeat kept advertising it, and with nothing on the wire to inspect. A
diagnosis is something to report, so the peer publishes it.
The categories are hw_joints (the motor bus), task_state (the running
rollout), sim_world and sim_joints.
Which robot is running¶
A sim peer's snapshot carries a robots section naming every robot in its world,
each with an active flag:
active means this robot is executing a policy right now. It is read from the
same in-flight population the status command answers robots_running from -
one call, _rollouts_in_flight, which every simulation backend answers from the
per-robot rollout claim it already keeps - so polling the topic and asking a peer
directly never disagree, on any backend. A rollout counts however it was
launched: one submitted in the background by start_policy and one being driven
right now by the blocking run_policy, which registers no future, both read
true. The scene's idle arms read false, which is what makes the one arm
running a rollout identifiable, and the flag clears when that policy is stopped
or its duration expires. Which robots exist is a separate question, answered by
sim_robots on the presence topic.
A peer that reports no in-flight population at all - a backend keeping no rollout
claim, or one whose world has been torn down - still has its robots named, with
no active key beside them, and the status command answers unknown. An
absent flag reads as "not reported"; false would be an affirmative "this robot
is idle" published on no evidence, which is indistinguishable from a rollout the
peer cannot see. A population that cannot be read is a failing probe, so it is
named under sim_world in degraded rather than answered with a flag nobody
measured.
Pose orientation¶
A robot that exposes a 4x4 SE(3) matrix as its pose provider has it decomposed
into x / y / z, a planar theta, and a quat. The quaternion is
scalar-first [w, x, y, z], unit length, and sign-canonicalized to w >= 0
(q and -q are the same rotation, so an unchanged pose reads back
identically). theta and quat are decomposed from the same matrix, so they
always agree: both describe the full rotation for every orientation, including
the half of SO(3) past 120 degrees that a robot turning back the way it came
lands in.
Out of contact vs gone¶
A peer that stops heartbeating is unreachable after PEER_TIMEOUT (10 s)
and, by default, deleted from the registry at that same moment. For fleets
whose silence is planned - a rover in an RF shadow, a warehouse robot crossing
a Wi-Fi dead zone, a satellite between ground-station passes - deletion answers
"was it ever here?" with "no": a dispatcher reading absence as loss fails work
over to another robot, and a fleet view renders a planned silence as a
vanished peer.
Set STRANDS_MESH_PEER_RETENTION_S to keep such peers on the books instead.
The peer stays in mesh.peers with reachable: false (a locally-derived
verdict the peer cannot claim about itself - it shares the collision rule
age has) until its silence exceeds max(PEER_TIMEOUT, retention), at which
point it is gone for real. Retention off (the default) is byte-identical to
the historic behavior. The STRANDS_MESH_MAX_PEERS eviction cap still
outranks retention: at the cap, the longest-silent peer is evicted first.
Readers that act on a peer record can state the freshness their decision
needs instead of parsing age themselves:
row = robot.mesh.get_peer(peer_id, max_age_s=30.0)
if row is None:
... # unknown OR older than 30 s - for this decision, the same thing
max_age_s=None (default) accepts any age - right for displays that render
staleness themselves. The bound must be positive and finite: nan would make
the comparison answer False for every age, a bound failing open on exactly
the stale record it was written to refuse, so it is refused instead.
Rejoining the mesh¶
stop() then start() is how a peer leaves and rejoins - after a config
change, or a hub that went away and came back. The peer keeps its identity
across it: the peer_id is unchanged, and an engaged e-stop lockout stays
engaged, so a network blip is not a way to forget a stop.
stop() waits for the sensor loops before it releases anything they publish
through, so by the time it returns the peer really is off the wire rather than
merely flagged as gone. The wait is bounded and shared across the loops: a sensor
read that blocks - a serial bus that stopped answering is the ordinary cause -
holds one tick open past the budget, and that loop is then named at WARNING as
still able to publish once more, instead of the stop being reported as complete.
What does not survive is your own subscribe() topics. start() re-declares
the peer's built-in topics from the table above; the subscribers subscribe()
returned are undeclared with the session reference and their callbacks are not
retained, so a rejoining consumer re-declares its own. stop() reports at INFO
how many it dropped and their names, and subscribe() says at WARNING when it
refuses - naming the topic and whether the peer is off the mesh, has no session,
or had the declare itself fail - so a rejoin that has not finished is visible
rather than a silent None.
sim.mesh.stop()
sim.mesh.start()
name = sim.mesh.subscribe("strands/*/state", callback=on_state)
if name is None:
... # the WARNING says which of the three refusals it was
Agent-driven mesh¶
from strands import Agent
from strands_robots import robot_mesh
agent = Agent(tools=[sim_a, robot_mesh])
agent("Find every robot on the mesh and ask each one to report its status")
agent("E-STOP all peers")
A single-peer stop is graded by the answer, not by delivery
robot_mesh(action="stop", target=...) reads the envelope Mesh.send returns rather than whether the send raised. A peer whose handler reports it did not stop (the same rule emergency_stop grades with), a peer-level type: error (a lockout, replay or authorization rejection), a send precondition error, or no answer inside the budget (the caller's timeout, capped at 5s) each make the result status="error" naming the peer and its answer, audit the verdict as a failure, and log at CRITICAL. A response that reports no verdict either way is not read as a refusal. The timeout reading is deliberately this action's own: a fleet-wide emergency_stop keeps counting a silent peer as a gap in its count rather than a refusal.
An empty peer list is not the same as no discovery
robot_mesh(action="peers") and action="status" answer from any process, including one with no local Robot()/Simulation() - such a process hears the fleet only through a robot-less gateway peer the tool brings up on demand. When that gateway does not come up, both actions report no discovery ran beside the count rather than an empty fleet nothing listened for, and name STRANDS_MESH when the kill switch is why: the standing "create a Robot()" remedy would be refused by that same switch. The audit record for the call carries discovery=none. A gateway that did come up and heard nothing keeps the plain 0 remote - that zero is a measurement.
Mesh teleop¶
# Machine A - leader publishes at 50 Hz # requires hardware
leader = Robot("so100", mode="real", mesh=True)
leader_arm = Teleoperator("so101_leader", port="/dev/ttyACM1", id="leader")
leader.start_teleop_publish(teleoperator=leader_arm,
device_name="leader", method="arm", hz=50)
# Machine B - follower applies incoming actions # requires hardware
follower = Robot("so100", mode="real", mesh=True)
follower.start_teleop_receive(source_peer_id=leader.mesh.peer_id,
device_name="leader", apply_fn=None)
leader.stop_teleop("leader")
follower.stop_teleop("leader")
get_teleop_status() on either side inspects current teleop state.
The counts it reports -- frames / frames_received, errors, rejected and the
rest -- are cumulative for the life of the publisher or receiver, while
hz_actual is the rate achieved by the session running now: start() opens a
new measurement window, so a stream stopped and started again reports the rate it
is running at rather than one averaged over both sessions. Compare hz_actual
against hz_target to judge a link; read the totals to judge the device.
Each published frame carries the operator's control signals from the
teleoperator's get_teleop_events() - terminate_episode, success,
rerecord_episode, is_intervention - alongside the joint action. Reading them
is best-effort: a teleoperator whose event surface stops answering (a keyboard
listener thread that died, a gamepad unplugged mid-session) never stops the joint
stream the follower is tracking. Because that field is also null for a leader
arm with no event surface at all, a failed read is reported on the publisher
rather than only on the wire - it increments event_read_errors in
get_teleop_status() and logs a warning naming the device and the cause, so an
operator whose signals are being dropped can see it.
source_peer_id and device_name are single segments of the mesh key
expression strands/{peer_id}/input/{device_name}, so both must be plain
identifiers ([A-Za-z0-9_.-]+, at most 128 chars). A Zenoh wildcard (*,
**) or an embedded / is refused with a ValidationError rather than
silently widening the stream: source_peer_id="**" would subscribe to
strands/**/input/leader and apply joint commands from every publishing peer,
not just the configured leader.
Attach a mesh to a Simulation¶
Robot(name, mode="sim", mesh=True) is the normal path: it resolves the
STRANDS_MESH kill switch, starts a client, and stores it on the engine. To
attach one to a Simulation you built yourself, start the client and assign it:
from strands_robots.mesh import init_mesh
from strands_robots.simulation import create_simulation
sim = create_simulation("mujoco")
sim.mesh = init_mesh(sim, peer_id="bench-sim") # None when mesh is disabled
The Simulation(mesh=...) constructor argument takes that same started client -
it is not a boolean opt-in switch, and a truthy value with no .stop() (notably
mesh=True) is rejected at construction. cleanup() stops the client before it
tears down MuJoCo; a stop that fails is logged and stepped over, so the world,
renderers and executor are always released.
Enable and disable¶
| Method | Scope |
|---|---|
Robot("so100", mesh=True) |
per-robot opt-in |
STRANDS_MESH=true (or 1/yes) |
process-wide opt-in for a bare Robot() |
sim.mesh = init_mesh(sim, ...) |
a Simulation built directly (see above) |
STRANDS_MESH=false |
process-wide kill switch, overrides mesh=True; also refuses the shared transport, so nothing in the process opens a session or binds the STRANDS_MESH_PORT listener |
Robot("so100", mesh=False) |
per-robot opt-out |
Unset STRANDS_MESH with no mesh= argument is the default, and it leaves the
mesh off.
Mesh failures are non-fatal - robot.mesh becomes None; the sim/hardware instance still works.
Transport selection: STRANDS_MESH_BACKEND¶
The mesh has three transports and one env var chooses between them at runtime. An install extra brings the client dependency; the env var picks which client the session actually constructs. Both are needed to move off the default: the extra without the variable installs code that never runs, and the variable without the extra selects a backend whose client is not importable.
| Value | Transport | Extra needed | Notes |
|---|---|---|---|
zenoh (default) |
Zenoh. The first process on a host listens on tcp/127.0.0.1:7447 (STRANDS_MESH_PORT) and later ones dial it; cross-host peers need ZENOH_CONNECT=tcp/<host>:7447. Multicast scouting is off by default. |
none - ships with strands-robots. |
STRANDS_MESH_MULTICAST=true opts into LAN scouting on 224.0.0.224:7446 - a group shared with every other Zenoh application on the LAN, not just this fleet, so any of them sees this peer's presence. |
iot |
AWS IoT Core MQTT with X.509 mutual TLS. | strands-robots[mesh-iot] (adds awsiotsdk). |
Requires STRANDS_IOT_ENDPOINT, STRANDS_IOT_THING_NAME, STRANDS_IOT_CERT_DIR. See Security. |
bridge |
Zenoh locally, mirrored to AWS IoT for fleet-wide fan-out. | strands-robots[mesh-iot]. |
A peer speaks Zenoh to its lab neighbours and IoT to the cloud on the same publish. |
# Local dev, nothing to set - peers on this host find each other through the local hub port.
export STRANDS_MESH_BACKEND=zenoh # or leave unset
# AWS IoT Core - the peers are on different networks.
export STRANDS_MESH_BACKEND=iot
# Both at once - a lab peer is reachable from a remote operator.
export STRANDS_MESH_BACKEND=bridge
Case and whitespace are normalised, so IOT and " iot " both select iot.
An unrecognized value (STRANDS_MESH_BACKEND=iott) falls back to zenoh and
is reported once per distinct offending value in the log - the policy is to
keep the mesh running rather than crash a host on a typo, and to make the
typo visible without one report per published message. The full vocabulary
lives in strands_robots/mesh/_backend_select.py, which is the sole owner
both the session gate and the transport factory read from.
See also¶
- Device Connect - the recommended networking layer this mesh backs.
- AI agents - drive the mesh with natural language.
- Architecture - where the mesh sits in the module map.
- Mesh source -
core.py,session.py,audit.py,sensors.py,input.py.