Write a contract

The contract defines the translation between ROS 2 topics and the keys LeRobot expects. Full field documentation: contract reference.

ROS 2 side

LeRobot side

/front_camera/image_raw/compressed

observation.images.front

/follower_arm/joint_states (position fields)

observation.state

/leader_arm/joint_states (position fields)

action

/task_prompt (String)

task

/reward_signal (Float64)

next.reward

On the ROS 2 side, data lives in typed messages on named topics with rich structure. On the LeRobot side, data lives in flat dictionaries with dot-separated string keys and numpy values. The contract maps one to the other, handling type conversion, field extraction, timestamp alignment, and resampling.

observation.state:
  channel: {topic: /follower_arm/joint_states, type: sensor_msgs/msg/JointState}
  align: {strategy: hold, timeline: header}
  select: [position.shoulder_pan, position.shoulder_lift, position.elbow]

At each timestep, this subscribes to /follower_arm/joint_states, extracts the named fields using dot notation (position.shoulder_panmsg.position[msg.name.index("shoulder_pan")]), assembles a numpy array, and stores it under observation.state.

Use timeline: header for stamped sensors with synced clocks, receive otherwise. Use strategy: hold unless a stale value is worse than a fabricated zero, since a gap zero-fills rather than skipping the frame.

Multi-source keys

A list value under one key declares ordered sources whose values are concatenated in declaration order:

observations:
  observation.state:
    - channel: {topic: /arm/joint_states, type: sensor_msgs/msg/JointState}
      align: {strategy: hold, timeline: header}
      select: [position.j1, position.j2, position.j3]
    - channel: {topic: /gripper/state, type: std_msgs/msg/Float32}
      align: {strategy: hold, timeline: receive}
      # Result: observation.state = [j1, j2, j3, gripper] (4D vector)

This matters because built-in policies look for specific key names by exact match. Feeding several topics into one key is how a rich robot stays compatible with them. Every source of a multi-source key needs a select and they must all resolve to the same dtype. Images never share a key.

The live LeRobot path enforces the same shape harder: at most one numeric observation key and at most one action key, or the contract is refused at deploy time, after the dataset has already trained fine. See key-count limit.

Action safety

channel.safety declares what the watchdog publishes when actions stop arriving: none (default), zeros, or hold. Never put a position-controlled arm on zeros: zero is a pose, and the arm will slam to it. hold lands in the same place before the channel has published once, since there is no last command to re-send.

Worked example

contracts/stone.yaml is the annotated tour: multi-source actions, teleop, QoS anchors, extended sections. It declares three numeric observation keys, so read it for the schema and do not copy its shape. so_101.yaml and turtlebot3.yaml are the deployable examples.