Device Connect¶
Device Connect by Arm is the recommended networking layer for Strands Robots — a device-aware runtime that handles discovery, presence, structured RPC, event routing, and safety. Robot("…").run() brings a robot online as a Device Connect device, and the robot_mesh tool dispatches through it.
A Strands agent controlling both a simulated and a real robot — across the country — over Device Connect.
Install¶
Without the extra, import strands_robots still works — everything falls back to the built-in Zenoh mesh. The one exception is Robot().run() below, which serves over Device Connect only.
Server mode — Robot().run()¶
from strands_robots import Robot
r = Robot("so100") # create the robot (optionally peer_id="so100-lab-1")
r.run() # serve on Device Connect (blocks until Ctrl+C)
.run() stops the auto-started built-in mesh and serves the robot over Device Connect (D2D Zenoh multicast, no broker). Without .run() the robot is agent-controlled — discovered and invoked remotely via robot_mesh / discover().
Ctrl+C releases the robot before the process exits. .run() is the only entry point that holds a robot for the life of a process, so the interrupt handler is the only teardown it gets: it calls the instance's cleanup(), which on hardware reaches the driver's own disconnect() — where torque disable and gripper release live — and closes the motors bus and every camera. The exit itself stays abrupt (cleanup() drains the task executor, and a wedged rollout must not turn one Ctrl+C into a process that never exits), so the release runs under a 5 s budget on a background thread. It prints <peer-id> stopped. only when the release finished; a teardown that times out or raises is reported as <peer-id> is exiting WITHOUT a completed shutdown: … so an operator is never told the arm is safe when it may still be holding torque. A released driver then stays released: disconnect() drops the driver's handle to the hardware link, so a movement RPC that arrives after the teardown is refused with … link not connected rather than reaching the wire and reporting success.
Because the mesh is stopped for Device Connect, a bring-up that fails leaves the process on no transport at all — so .run() logs the cause (naming the extra when that is what is missing) and prints <peer-id> is NOT online instead of announcing a device that is not there. It keeps running, so a broker that comes back is not a lost process — except when the [device-connect] extra itself is missing: nothing inside the process can install it, so that case releases the robot and exits 1 with the install command.
Secure by default
.run() refuses to start on a transport nobody authenticates: with no credentials configured it exits the bring-up with an error naming both remedies instead of coming online in plaintext. To run authenticated + encrypted, point it at a bundled credentials file (MESSAGING_CREDENTIALS_FILE — a single *.creds.json with the CA, cert and key) or a tls/ endpoint — this works broker-less (D2D) or brokered, they're independent choices. For a quick trial on a trusted, isolated LAN you can instead skip auth (a warning is logged while active) — and then restrict who may drive the robot, because on a plaintext transport a caller's id is whatever it claims:
export DEVICE_CONNECT_ALLOW_INSECURE=true
export DEVICE_CONNECT_RPC_ALLOW=my-agent # matches STRANDS_ROBOT_MESH_AGENT_ID on the agent side
Drivers¶
Each robot is wrapped as a Device Connect device by a DeviceDriver adapter:
| Driver | Wraps | Exposes |
|---|---|---|
SimulationDeviceDriver |
a MuJoCo Simulation |
execute, getFeatures, getStatus, reset, step, stop |
RobotDeviceDriver |
a hardware Robot or a native driver |
the same RPC surface, driving real servos |
ReachyMiniDriver |
a Pollen Reachy Mini | device-native RPCs (look, nod, …) over Zenoh / WebSocket |
stop reports what it halted rather than asserting that it did. On a simulation
it routes every robot through the simulation's own stop_policy and answers with
the rollouts that really were in flight (stopped) and any that refused
(not_stopped, under status="error"), so an idle simulation and a halted
rollout are different answers and either can be checked against
list_policies_running, which is a SimEngine verb and so answers whichever
backend the driver is holding. A simulation with nothing to halt answers
status="success" with an empty stopped list — never an error, because a peer
reported as "did not stop" when it had nothing to stop is the false alarm that
teaches an operator to ignore the warning.
getState reports joint positions for either kind of robot. A lerobot robot is a
wrapper holding the device that owns the motor bus, while a native driver
(driver="strands") owns its bus directly and
so is that device — Robot(mode="real") attaches Device Connect to both, and the
RPC resolves which one to read through the same resolution the mesh state topic
uses. The read goes through the shared motor-bus lock, so it waits its turn behind
an in-flight rollout, teleop write or mesh probe rather than colliding with it, and
it reads the motors directly: a robot whose camera is failing still reports where
its joints are. A robot that can answer no joint read at all is reported without a
joints key rather than as an error.
Published state events¶
Besides answering RPCs, a driver publishes its own state on a 10 Hz loop while a
policy is running. SimulationDeviceDriver emits two events:
| Event | Payload |
|---|---|
stateUpdate |
sim_time, step_count, and running_policies ({robot: {steps, instruction}}) |
observationUpdate |
robot_name, sim_time, step_count, and joints |
joints is {joint name: position} in radians, and carries exactly the
per-joint scalars the simulation's own
get_observation reports for that robot:
- a value is read at the joint's own qpos address, so a robot with a floating base reports its leg and arm angles rather than components of its base pose;
- a floating base has no scalar joint position (its state is a position plus a
quaternion), so it is not a key in
joints- subscribe to the simulation's observation forbase_pos/base_quatif you need the base pose; - only that robot's own joints appear, under their short names, whatever the compiled model namespaces them to in a multi-robot scene.
init_device_connect() / init_device_connect_sync() attach the right driver and start the runtime — Robot().run() calls these for you.
Driving it from an agent¶
The robot_mesh tool is the single entry point — the same tool for Device Connect and the mesh:
from strands_robots.tools.robot_mesh import robot_mesh
robot_mesh(action="peers") # discover (read-only)
robot_mesh(action="tell", target="so100-lab-1", # run a policy (HITL-approved)
instruction="pick up the cube", policy_provider="mock")
robot_mesh(action="emergency_stop") # e-stop the fleet (HITL-approved)
How a transport is chosen¶
robot_mesh() runs every safety gate first, then tries Device Connect, then falls back to the mesh:
Device Connect handles the action when all of these hold (otherwise it returns control to the mesh):
STRANDS_ROBOT_MESH_DCis on (the default; the test suite sets it off),- the action isn't mesh-only (
subscribe/watch/inbox/unsubscribe), - the agent-side connection establishes, and
- at least one Device Connect device is discovered.
Because the safety gates run above dispatch, Device Connect inherits the same rate limiting, validation, audit, and approval as the mesh.
Safety¶
Human-in-the-loop on actuation
The actuation actions — tell, send, stop, broadcast, emergency_stop, rpc — are gated behind an out-of-band operator approval (tool_context.interrupt), so they run only inside a Strands agent loop where a human approves. Called from a bare script they fail closed. Read-only peers works anywhere. Approval is delivered outside the LLM's tool arguments, so prompt injection can't smuggle it. The gated set is configurable via STRANDS_MESH_HITL_ACTIONS.
A stop is graded by the device's answer, not by delivery
A device answers its stop RPC with an envelope, so an authorization refusal or a stop_policy that could not halt a rollout arrives as a delivered reply rather than as a transport error. stop and emergency_stop both read that reply: a device reporting it did not stop makes the result status="error" naming the device and its answer, audits the verdict as a failure, and logs at CRITICAL. A reply that reports no verdict either way is not read as a refusal, so an unreachable device stays a gap rather than a claimed stop.
Architecture¶
graph TD
D1["d1<br/>Robot('so100').run()"]
D2["d2<br/>Robot('aloha').run()"]
AGENT["agent<br/>Agent(tools=#91;robot_mesh#93;)"]
D1 --- ZD2D(["Zenoh D2D"])
D2 --- ZD2D
AGENT --- ZD2D
ZD2D -.->|optional| BROKER["Broker<br/>Zenoh router / NATS / MQTT"]
Environment variables¶
Most settings have safe defaults — Device Connect runs on a LAN out of the box. The one real choice is transport security: it's secure by default and expects mTLS certificates unless you explicitly opt into insecure transport. This is independent of topology — a broker-less (D2D) run can be authenticated too; the brokered setup just adds registry-based authorization on top.
Reference¶
You rarely touch more than one or two of these. Grouped by what they control:
Transport security — how it's authenticated¶
| Variable | Default | What it does |
|---|---|---|
MESSAGING_CREDENTIALS_FILE |
unset | The one var to enable mTLS. A single *.creds.json bundling CA + cert + key. Works D2D or brokered. |
DEVICE_CONNECT_ALLOW_INSECURE |
unset (secure) | true/1/yes = skip auth/encryption; every other spelling is secure. Trusted, isolated LAN only; logs a warning. The string vocabulary is this variable's - the allow_insecure= argument must be a boolean and refuses a string, since "false" is truthy. The argument outranks this variable, and the posture it resolves to is what the allowlist advisory below follows - so a device brought up with allow_insecure=True and this variable unset is still reported as insecure. |
ReachyMiniDriver reaches its robot over a second link the variables above do not
cover: the Reachy Mini daemon's own REST / WebSocket interface. That link is
plaintext by default, and three variables secure it - the token authenticates,
only REACHY_DAEMON_TLS encrypts, so they are only useful together.
| Variable | Default | What it does |
|---|---|---|
REACHY_DAEMON_TLS |
unset - plaintext http:// / ws:// |
1/true/yes/on (any case) upgrades the daemon link to https:// / wss://. Until it is set the channel is unencrypted, so the token below and every actuator command cross the network in the clear, where they can be sniffed or replayed. |
REACHY_DAEMON_TOKEN |
unset | Bearer credential the daemon authenticates the caller with; unset logs a one-time warning that the link is unauthenticated. It authenticates, it does not encrypt - on the default plaintext link the token itself is sent in the clear, so set REACHY_DAEMON_TLS alongside it. |
REACHY_DAEMON_TLS_INSECURE |
unset - certificate verified | 1/true/yes/on (any case) keeps the encryption but stops verifying the daemon's certificate - for a self-signed daemon with no CA provisioned yet. A one-time warning keeps the weakened posture visible. |
Authorization & safety — who may do what¶
| Variable | Default | What it does |
|---|---|---|
STRANDS_MESH_HITL_ACTIONS |
built-in set | Which actions need operator (human-in-the-loop) approval. |
DEVICE_CONNECT_RPC_ALLOW |
nobody (unset refuses every state-mutating RPC) | Caller allowlist for state-mutating RPCs (execute/stop/step/reset); * globs; a literal * allows every named caller and logs a warning. Set it before the device will act. |
DEVICE_CONNECT_ESTOP_ALLOW |
inherits DEVICE_CONNECT_RPC_ALLOW |
Caller allowlist for emergencyStop. Unset - or holding no entry after stripping, so "", " " and "," all count - falls back to the RPC allowlist. With neither set, a named caller may still stop the robot (stopping never gets harder than moving); an anonymous one may not. |
STRANDS_ROBOT_MESH_AGENT_ID |
anonymous | Caller id the agent presents — required when a device sets an allowlist (else it's denied). |
Other¶
| Variable | Default | What it does |
|---|---|---|
STRANDS_ROBOT_MESH_DC |
on |
off makes robot_mesh() skip Device Connect and use the built-in mesh only. |
Allowlists are a hard boundary only under mTLS
Over insecure D2D the caller id is self-asserted, so DEVICE_CONNECT_RPC_ALLOW / DEVICE_CONNECT_ESTOP_ALLOW are advisory (a one-time warning is logged). For a real authorization boundary, run the brokered setup with mTLS, which binds the caller id to the sender's certificate.
Full infrastructure (optional)¶
D2D is enough for a single LAN. For production — many devices, multiple sites, real authorization — add the Device Connect server stack: a Zenoh router (or NATS / MQTT broker) + etcd + the device registry.
What it adds over D2D
- Persistent registry — devices register with TTL leases; discover them by type, location, or capability.
- Authorization — per-device ACLs, a central CA, and certificate revocation (the allowlists above become a hard boundary).
- Distributed state & locks — etcd-backed coordination, e.g. one agent per arm.
- Cross-network routing — across subnets and sites, not just the local LAN.
Switching to it — every example on this page works unchanged:
- Start the stack — Docker Compose brings up the Zenoh router, etcd, and registry.
- Point peers at it — set
MESSAGING_BACKEND,ZENOH_CONNECT=<router>, and your mTLSMESSAGING_CREDENTIALS_FILE. - Run as usual — devices auto-register, and discovery flows through the registry instead of multicast scouting.
The broker-specific variables (everything else is shared with D2D):
| Variable | Set to | Why |
|---|---|---|
MESSAGING_BACKEND |
zenoh / nats / mqtt |
Which broker protocol the stack runs |
ZENOH_CONNECT |
tcp/<router-host>:7447 |
The router / broker endpoint (replaces LAN multicast) |
See the GUIDE for the Docker Compose file.
See also¶
- Device Connect GUIDE — full end-to-end demo (D2D + full infra), Reachy Mini, and smoke tests.
- Multi-robot mesh — the built-in Zenoh fallback layer.
- AI agents — drive it with natural language.
- Device Connect source — drivers and runtime wiring.