> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroruntime.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Recording format

> The files a recorded session writes, how the streams join, and the counters to check before training.

A session writes one directory, with one subdirectory per episode.

```
sessions/<session_id>/
  manifest.json                    descriptor, safety config, wire version
  events.jsonl                     lease, watchdog, estop, state changes
  metrics.jsonl                    1 Hz stats snapshots
  episodes/<episode_id>/
    episode.json                   task, counts, drop accounting, camera skew
    observations.jsonl
    commands.jsonl
    applied.jsonl
    states.jsonl
    cam_<label>/<frame_id>.jpg
```

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.

| File | Key | Carries |
| - | - | - |
| `observations.jsonl` | `oid` | one frame id per camera label, and the state it went with |
| `commands.jsonl` | `cid` | `seq`, the `oid` the leader was acting on, staleness |
| `applied.jsonl` | `cid` | what was written, what was asked for, which limits fired |
| `states.jsonl` | `sseq` | joints, load, temperature, and the command that preceded them |

Every edge the exporter needs is a foreign key on a row, so the relationships
survive export instead of being guessed back from timestamps.

<Note>
  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.
</Note>

## 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()`](/robotics/guides/monitoring#metrics).

<Note>
  Both are keyed by `kind` rather than positionally, so new kinds and new fields
  can appear. Read what you recognise and ignore the rest.
</Note>

## Manifest

`manifest.json` freezes the descriptor and the safety config the session ran
with.

| Key | Why it is there |
| - | - |
| `schema` | the dataset layout version, `teleop-staging/3` today. Check it before parsing |
| `session_id`, `created_at`, `wire_version` | what wrote this session |
| `robot.groups`, `robot.schema_hash` | the joint layout the rows are in |
| `safety.slew` | what you configured |
| `safety.slew_resolved` | what each joint actually got, with its units |
| `safety.watchdog_timeout_s`, `max_staleness_ms`, `on_starvation` | the rest of the envelope |

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`.

<Warning>
  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.
</Warning>

## Episode metadata

| Key | What it is |
| - | - |
| `task` | the string passed to `start_episode()` |
| `started_at`, `ended_at` | both ends, so duration is readable without scanning rows |
| `success` | `true`, `false`, or `null` for not judged |
| `camera_skew_ms_max` | the worst spread between cameras in this episode |
| `images_per_label` | how many frames each camera contributed |

## Recorder settings

`start_recording()` passes anything extra through to the recorder. The defaults
are right for a 50 Hz arm with two cameras.

| Argument | Default | What it does |
| - | - | - |
| `root` | `"./sessions"` | where sessions are written |
| `record_video` | `True` | `False` records rows and no images |
| `frame_queue_depth` | `2048` | rows waiting to be written. Small dicts, so this is cheap |
| `image_queue_depth` | `64` | decoded frames held by reference. About 920 KB each at 640 by 480 |
| `jpeg_quality` | `90` | image quality on disk |
| `dataset_fps` | `30` | recorded in the manifest so the exporter can sanity-check what it finds. It does not set the capture rate |

```python theme={null}
follower.start_recording("./sessions", image_queue_depth=128, jpeg_quality=80)
```

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.

## Related

<CardGroup cols={2}>
  <Card title="Exporting datasets" icon="table" href="/robotics/guides/exporting-datasets">
    Join these streams into rows and write a LeRobot dataset.
  </Card>

  <Card title="Recording" icon="floppy-disk" href="/robotics/concepts/recording">
    Turning recording on, and how episodes are bounded.
  </Card>

  <Card title="Sync" icon="film" href="/robotics/concepts/sync">
    How frames and joint readings are matched to one instant.
  </Card>

  <Card title="Cloud recording" icon="cloud" href="/robotics/guides/cloud-recording">
    Uploading episodes to your own storage alongside the local copy.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.