Skip to content

ROS 2

Three ROS 2 transports, when to use each, one operator gate, what a ROS 2 node can still tell apart.

This page shows the three ways an agent or a Robot reaches a ROS 2 graph, which fits your machine, how a real arm joins the graph in both directions, and where the operator gate sits on each.

from strands import Agent
from strands_robots import use_ros, use_rosbridge, use_rtps

agent = Agent(tools=[use_rtps])                  # no ROS install needed on this machine
agent("List the topics on the graph, then echo /odom once.")

Three transports

Left column, three cards under the agent: use_ros, in-process rclpy with a sourced ROS 2 distro, services and actions; use_rosbridge, a WebSocket to rosbridge_server on the robot through roslibpy, works from a laptop; use_rtps, raw DDS as a first-class participant through cyclonedds, no distro, topics only. Middle, the one green element: gate_command in strands_robots._command_gate, which every publish, service_call or action_send_goal aimed at a blocklisted name passes through, whichever transport carries it; reads are never gated. Right: the ROS 2 graph, topics such as /cmd_vel and /joint_states, services, actions; and a real arm on it, Robot(ros2_bridge=True) publishing /<robot>/joint_states and /<robot>/<camera>/image_raw. Footnote: STRANDS_ROS2_COMMAND_ALLOW pre-approves names by base name; rosbridge is unauthenticated by default.Left column, three cards under the agent: use_ros, in-process rclpy with a sourced ROS 2 distro, services and actions; use_rosbridge, a WebSocket to rosbridge_server on the robot through roslibpy, works from a laptop; use_rtps, raw DDS as a first-class participant through cyclonedds, no distro, topics only. Middle, the one green element: gate_command in strands_robots._command_gate, which every publish, service_call or action_send_goal aimed at a blocklisted name passes through, whichever transport carries it; reads are never gated. Right: the ROS 2 graph, topics such as /cmd_vel and /joint_states, services, actions; and a real arm on it, Robot(ros2_bridge=True) publishing /<robot>/joint_states and /<robot>/<camera>/image_raw. Footnote: STRANDS_ROS2_COMMAND_ALLOW pre-approves names by base name; rosbridge is unauthenticated by default.
tool wire needs on this machine reaches verbs
use_ros in-process rclpy a sourced ROS 2 distro (source /opt/ros/jazzy/setup.bash); rclpy is not on PyPI any ROS 2 graph, with services and actions status, list_topics, list_nodes, list_services, list_actions, info, echo, publish, service_call, action_send_goal
use_rosbridge WebSocket to rosbridge_server on the robot pip install 'strands-robots[rosbridge]' (roslibpy), nothing else; works from macOS and CI ROS 1 and ROS 2 robots running rosbridge with rosapi; port 9090 status, list_topics, list_services, echo, publish, service_call
use_rtps raw DDS/RTPS as a first-class participant pip install 'strands-robots[ros2]' (cyclonedds); no distro, no rclpy every ROS 2 distro (Humble, Jazzy, Rolling) over one implementation; topics only status, types, advertise, publish, subscribe, echo

Pick use_ros on the robot or a ROS workstation for services or actions; use_rosbridge from a laptop when the robot runs rosbridge; use_rtps with no ROS install near the agent, or when the agent must be a robot: an RTPS participant advertises topics a node consumes and subscribes to command topics.

Types are resolved dynamically on use_ros (rosidl_runtime_py, any interface installed in the distro) and on use_rosbridge (ROS 1 style two-segment names, geometry_msgs/Twist). use_rtps must own a type definition locally, so it ships a curated IDL bundle of common messages (strands_robots.rtps.idl, listed by action="types"); a custom message waits on dynamic types in cyclonedds. Graph metadata differs too (below).

rosbridge is unauthenticated by default. Use it on a network you trust.

The gate is the same on all three

A publish, service_call or action_send_goal aimed at a blocklisted name goes through strands_robots._command_gate.gate_command, whichever transport carries it. The blocklist matches the final path segment, so /cmd_vel covers /robot1/cmd_vel:

/cmd_vel, /cmd_vel_unstamped, /manual_drive, /joint_command, /joint_trajectory, /joint_trajectory_controller/joint_trajectory, /emergency_stop, /e_stop, /motor_enable, /enable_motor, /disable_motor, /vehicle_state, /enable_state, /navigate_to_pose, /follow_path.

Reads are never gated, nor is use_rtps's advertise (it creates a publisher and writes nothing). STRANDS_ROS2_COMMAND_ALLOW pre-approves names, comma-separated, by base name: a bare /cmd_vel lifts the gate on every namespaced /cmd_vel, so write /robot_a/cmd_vel to scope it (* matches nothing here on purpose); BYPASS_TOOL_CONSENT=true lifts the gate with a warning. Every transport asks the gate after the backend probe and argument checks and before any lock or dial (the operator gate).

A real arm on the graph

arm = Robot("so101", mode="real", port="/dev/ttyACM0", driver="lerobot",
            ros2_bridge=True, ros2_transport="rtps", ros2_domain=0, ros2_commands=True,
            dds_security_config={"identity_ca": "file:ca.pem", "certificate": "file:arm.pem",
                                 "private_key": "file:arm.key", "governance": "file:gov.p7s",
                                 "permissions": "file:perm.p7s"})

ros2_bridge=True on the lerobot-backed Robot publishes /<robot>/joint_states (sensor_msgs/msg/JointState) and /<robot>/<camera>/image_raw (sensor_msgs/msg/Image, rgb8) from the control loop, and subscribes /<robot>/joint_command (JointState), forwarding each into send_action so MoveIt or teleop can drive the arm. ros2_transport is "rclpy" (HardwareRosBridge) or "rtps" (HardwareRtpsBridge); ros2_commands=False is publish-only; joint_limits= clamps inbound targets per joint.

On the rtps transport an enabled command surface lets any DDS participant on the domain move the arm, so it requires dds_security_config (identity CA, certificate, private key, governance, permissions, each a path or file:/data: URI) or the explicit opt-out STRANDS_ROS2_BRIDGE_I_KNOW_THIS_IS_INSECURE=1. A missing rclpy on the rclpy transport is a named refusal suggesting ros2_transport='rtps'.

A sim Robot(..., ros2_bridge=True) publishes joint_states and image_raw over rclpy only.

What a ROS 2 node can still tell apart

The payload is identical; the graph metadata is not. The rtps bridge is a bare DDS participant: ros2 topic echo decodes it field for field, ros2 node list omits it, and ros2 topic info -v shows Node name: _CREATED_BY_BARE_DDS_APP_, an INVALID type hash and KEEP_LAST (1) history with no depth knob. Tooling that waits for a node or a matching type hash wants rclpy.

Linux aarch64 (Jetson)

No cyclonedds release ships a Linux aarch64 wheel, so on a Jetson pip install 'strands-robots[ros2]' builds the sdist against a Cyclone DDS C install named by CYCLONEDDS_HOME:

# (a) a sourced ROS 2 distro already ships it
sudo apt install ros-$ROS_DISTRO-cyclonedds
CYCLONEDDS_HOME=/opt/ros/$ROS_DISTRO pip install 'strands-robots[ros2]'
# (b) no ROS 2: build from https://github.com/eclipse-cyclonedds/cyclonedds
cmake -S cyclonedds -B cyclonedds/build -DCMAKE_INSTALL_PREFIX=$HOME/cyclonedds && cmake --build cyclonedds/build --target install
export CYCLONEDDS_HOME=$HOME/cyclonedds     # keep exported at runtime
pip install 'strands-robots[ros2]'

The loader tries a wheel's bundled copy, then $CYCLONEDDS_HOME/lib, then the system path, so an install to the default prefix that ldconfig knows needs no variable; a set but stale CYCLONEDDS_HOME fails hard with CycloneDDSLoaderException instead of falling back.

A ROS robot on the mesh

RosBridgedRobot (rclpy), RosbridgeRobot (WebSocket) and RtpsRobot (DDS) in strands_robots.mesh wrap a mobile base that speaks /cmd_vel and /odom as a mesh peer answering status, execute and stop next to the arms (fleet); AckermannRosRobot does the same for a car-like base. The Yahboom M3 Pro driver is the shipped example of a whole robot driven through its ROS 2 graph (drivers).

Edit page