A session writes one directory, with one subdirectory per episode.
Images are named by frame id, not by row index. A row references the frame ids it used, so a missing image is visible rather than shifting every later frame by one.

Streams

Observations, commands, applied actions and states happen at different rates, so they are four append-only files joined by explicit ids rather than one row per tick. Every edge the exporter needs is a foreign key on a row, so the relationships survive export instead of being guessed back from timestamps.
This is why a command can say “I was acting on an observation from three ticks ago” and have that be true in the dataset. Flattening to one row per tick cannot express it.

Session streams

Two files sit beside the episodes and cover the whole session. events.jsonl is one object per line: ts, kind, and whatever that kind carries. The kinds are lease_granted, lease_released, safe_state, disconnected, telemetry_error, episode_start, episode_end, episode_refused and cloud_recording. It is the timeline of everything that happened to the session that was not a joint value. metrics.jsonl is a ts plus a flat stats snapshot, once a second. Same keys as metrics().
Both are keyed by kind rather than positionally, so new kinds and new fields can appear. Read what you recognise and ignore the rest.

Manifest

manifest.json freezes the descriptor and the safety config the session ran with. A policy trained on clamped actions inherits those clamps, so a dataset recorded with slew=3 and one recorded with slew=10 are not the same distribution. slew_resolved is per joint because slew may be a mapping and units belong to the group, so one number for the whole machine is wrong on anything that mixes spaces.

Recording is best effort

Recording never stalls the control loop. If the disk cannot keep up, rows are dropped rather than allowed to block, and every drop is counted in episode.json.
Check the dropped counts before you train on a session. Most of them mean the dataset is thinner than you expected. structure and orphan_rows mean episode boundaries were lost, which corrupts the episode rather than shortening it, so those two must be zero.

Episode metadata

Recorder settings

start_recording() passes anything extra through to the recorder. The defaults are right for a 50 Hz arm with two cameras.
Raise image_queue_depth only if you have the memory. Images are the droppable payload and rows are not, which is why the two are queued separately.

Exporting datasets

Join these streams into rows and write a LeRobot dataset.

Recording

Turning recording on, and how episodes are bounded.

Sync

How frames and joint readings are matched to one instant.

Cloud recording

Uploading episodes to your own storage alongside the local copy.