# Commands

> Full command reference for the Qualia CLI

## Global Options

These flags go before the command and work with any command that calls the API.
Each one overrides its environment variable, which overrides the config file
that `qualia auth login` writes.

| Option        | Env var            | Description                                                                                                     |
| ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
| `--token`     | `QUALIA_API_KEY`   | API key                                                                                                         |
| `--base-url`  | `QUALIA_BASE_URL`  | API origin, e.g. `https://dev-api.qualiastudios.dev` (default `https://api.qualiastudios.dev`)                  |
| `--account`   | `QUALIA_ACCOUNT`   | Optional. Account UUID to act on; default is the API key's own account ([more](https://junction-docs.qualiastudios.dev/cli/overview.md#3-your-account))  |
| `--workspace` | `QUALIA_WORKSPACE` | Workspace UUID to act in instead of the API key's own workspace; never stored in the config file; rarely needed |

```bash
qualia --token <API_KEY> credit
qualia task list   # the API key's own account; no --account needed
```

`qualia auth status` shows the stored config and the environment variables, not
these flags.

---

## `qualia version`

Show CLI and SDK versions. `qualia --version` prints the same.

```bash
qualia version
qualia --version
```

---

## `qualia auth`

### `qualia auth login`

Save your API key, and optionally the API origin and an account, to the config
file. Prompts for the key when `--api-key` is left out, and hides what you type.

```bash
qualia auth login --base-url https://junction-api.qualiastudios.dev
```

| Option                 | Description                                                                                              |
| ---------------------- | -------------------------------------------------------------------------------------------------------- |
| `--api-key`, `--token` | API key (prompted if omitted)                                                                            |
| `--base-url`           | API origin, no path. Default `https://api.qualiastudios.dev`; dev is `https://dev-api.qualiastudios.dev` |
| `--account`            | Optional. Account UUID, lowercase; `accounts/<uuid>` also works. Default: the API key's own account      |

Each login replaces the stored key, base URL and account. Leave out `--base-url`
or `--account` and that value is no longer stored. Where the file lives and how
to find your account id: [CLI overview](https://junction-docs.qualiastudios.dev/cli/overview.md#authentication).

### `qualia auth status`

Show the stored login: whether a key is set (never the key), the base URL, and
the account (the API key's own unless one is stored).

```bash
qualia auth status
```

```text
API key: configured
Base URL: https://junction-api.qualiastudios.dev
Account: the API key's own
```

It reads the config file and the `QUALIA_*` environment variables, not the
global flags. It exits 1 with `Invalid authentication config: …` when the
stored key or base URL is malformed. It does not call the API, so it does not
prove the key works.

---

## `qualia credit`

Show your current credit balance and how it splits: starter credits, prepaid
credits and each voucher, left of granted.

```bash
qualia credit
qualia credit --json
```

```text
Credits: 1700
  Starter: 80 of 100
  Prepaid: 1,000 of 1,000
  Voucher Launch: 620 of 1,000, expires 2026-12-31
```

The split is for members of the organization. When the key's creator is no
longer a member, only the total prints (`null` fields in `--json`).

---

## `qualia activity`

What people and their keys did: the dashboard's Activity table. A workspace's
activity is the one `--workspace` names, else the key's own; `--org` reads the
whole organization's, every workspace's and its own. Reading needs `data:read`.
A key restricted to workspaces cannot read activity.

### `qualia activity list`

List activity, newest first.

```bash
qualia activity list
qualia activity list --org --filter 'verb = KEEP AND actor = API_KEY' --json
qualia activity list --filter 'create_time >= "2026-10-01T00:00:00Z"' -n 100
```

| Option           | Description                                                                                                                            |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `--org`          | The organization's activity, not one workspace's                                                                                       |
| `-f`, `--filter` | AIP-160 comparisons joined by `AND`: `verb`, `actor` (`PERSON`, `API_KEY`, …), `workspace`, `text`, `create_time`, `subject`, `action` |
| `--raw`          | Every recorded event (`action`, `summary`), not only intentional actions                                                               |
| `-n`, `--limit`  | Page size; at most 100 for activity                                                                                                    |
| `--cursor`       | The `nextPageToken` of the previous page                                                                                               |
| `--json`         | `{"activity": [...], "nextPageToken": ...}` (`{"events": [...]}` with `--raw`)                                                         |

The table names who acted where the record has a name, and prints each row's
event id. `--json` prints full event names.

### `qualia activity show`

Show one event as the record holds it. `--details` adds what it touched (its
episodes and their state before) and the fields it changed.

```bash
qualia activity show <EVENT_ID>
qualia activity show accounts/<ACCOUNT>/workspaces/<WORKSPACE>/events/<EVENT_ID> --details --json
```

An id is read through the organization. A full name, which
`qualia activity list --json` prints, works too.

### `qualia activity export`

Download the organization's events record as JSON lines: one event per line,
oldest first, every workspace's and the organization's own.

```bash
qualia activity export -o events.jsonl
qualia activity export -o october.jsonl --start 2026-10-01T00:00:00Z --end 2026-11-01T00:00:00Z
```

| Option           | Description                                               |
| ---------------- | --------------------------------------------------------- |
| `-o`, `--output` | Where to write the file (required)                        |
| `--start`        | Events created at or after it (RFC 3339, with a timezone) |
| `--end`          | Events created before it                                  |
| `--json`         | `{"path": ..., "events": N}`                              |

The file is personal data: member events carry usernames and emails. It needs a
key with `audit:read`, not restricted to workspaces, whose creator is an Admin
or Owner. The file appears only once the whole record has arrived; a download
that breaks leaves nothing behind.

---

## `qualia integration`

The organization's Hugging Face tokens: new runs use the default one.

Every command needs a key with the opt-in `integrations:write` scope (`*` does
not grant it), made by a Contributor, Admin or Owner of the organization, and
not restricted to workspaces. A key's value goes in once and is never shown
again: the list prints its last four characters.

### `qualia integration list`

List the organization's integration keys, newest first.

```bash
qualia integration list
qualia integration list --json
```

```text
ID                                    Provider     Name     Key      Default  Added
550e8400-e29b-41d4-a716-446655440000  huggingface  ci       ...9f3a  yes      2026-10-30
660e8400-e29b-41d4-a716-446655440000  huggingface  lab      ...41bc           2026-10-29
```

`--json` prints `{"integrations": [{"id", "provider", "name", "last4",
"is_default", "created_by", "created_at", "expires_at"}]}`. `last4` is `null`
for a key shorter than 12 characters.

### `qualia integration add`

Add a key. The key is piped in with `--secret-stdin`, or typed at a prompt that
does not echo; on a terminal it always prompts. It is never taken from the
command line, where it would stay in your shell history.

```bash
qualia integration add huggingface --name lab      # prompts for the token
printf %s "$HF_TOKEN" | qualia integration add huggingface --name ci --secret-stdin --json
```

| Argument / Option | Description                                              |
| ----------------- | -------------------------------------------------------- |
| `PROVIDER`        | `huggingface`                                            |
| `--name`          | Up to 15 characters (required)                           |
| `--secret-stdin`  | Read the key from stdin; one trailing newline is dropped |
| `--json`          | Print the key as it is listed                            |

A Hugging Face token starts with `hf_`. The organization's first token becomes
the default.

### `qualia integration remove`

Remove a key. Workspaces on a removed token move to the default.

```bash
qualia integration remove <ID>
qualia integration remove <ID> --yes --json
```

Without a terminal it needs `--yes`.

### `qualia integration default`

Make a Hugging Face token the one new runs use.

```bash
qualia integration default <ID>
```

---

## `qualia member`

Who is in the organization or a workspace, at which role, and changing it: the
dashboard's Members pages. Without `--project` a command is about the
organization; with it, about that workspace.

A key needs the opt-in `members:read` scope to read and `members:write` to
change (see [Admin scopes](https://junction-docs.qualiastudios.dev/cli/overview.md#admin-scopes)); reading a space also
takes `projects:read`. A key acts as the person who made it: it never gives a
role above that person's own, and only an admin or owner manages people. A
workspace key acts only on its own workspace, so it always passes `--project`.

### `qualia member list`

List everyone with access, their role and where it comes from: granted here,
from the organization, or through a space's members.

```bash
qualia member list
qualia member list --project <PROJECT_ID> --json
```

| Option            | Description                                                             |
| ----------------- | ----------------------------------------------------------------------- |
| `-p`, `--project` | A workspace; without it, the organization                               |
| `--json`          | `{"space", "people": [...], "can_manage_members", "my_role"}` as served |

### `qualia member show`

Show one person's role on every space of the organization you can read, and
the role granted on each. Needs an organization key.

```bash
qualia member show <USER_ID>
```

### `qualia member role`

Give a person a role, or move them to another: on the organization, or on one
workspace with `--project`. An organization role is for the organization's
members; invite anyone else. A workspace role raises a member above their
organization role there, or makes someone a guest of it.

```bash
qualia member role <USER_ID> contributor
qualia member role <USER_ID> admin --project <PROJECT_ID> --json
```

| Argument / Option | Description                                                         |
| ----------------- | ------------------------------------------------------------------- |
| `USER_ID`         | The person (required)                                               |
| `ROLE`            | `viewer`, `contributor`, `admin` or `owner`                         |
| `-p`, `--project` | A workspace; without it, the organization                           |
| `--json`          | The role before (`from_role`) and after, and the role they hold now |

### `qualia member remove`

Take a person's role on one workspace away (`--project`; they keep what the
organization gives them), or remove them from the organization. `--keep` names
the workspaces they stay a guest of. A key cannot remove the person who made
it from the organization: leaving one takes a signed-in session.

```bash
qualia member remove <USER_ID> --project <PROJECT_ID> --yes
qualia member remove <USER_ID> --keep <PROJECT_ID> --yes
```

| Option            | Description                                                                   |
| ----------------- | ----------------------------------------------------------------------------- |
| `-p`, `--project` | A workspace; without it, the organization                                     |
| `--keep`          | Removing from the organization: a workspace they stay a guest of (repeatable) |
| `-y`, `--yes`     | Skip the confirmation (needed without a terminal)                             |
| `--json`          | What they hold now (`effective_role`), and the workspaces kept                |

---

## `qualia invitation`

Invitations to the organization or a workspace. The same scopes and rules as
`qualia member`.

### `qualia invitation list`

List the pending invitations: to one workspace with `--project`, or every one
in the organization, each naming its space.

```bash
qualia invitation list
qualia invitation list --project <PROJECT_ID> --json
```

### `qualia invitation send`

Invite someone by email at a role: to the organization, or to one workspace as
a guest with `--project`. Never above your own role. Someone already in the
organization gets a role with `qualia member role` instead.

```bash
qualia invitation send new@example.com --role viewer
qualia invitation send new@example.com --role contributor --project <PROJECT_ID>
```

| Argument / Option | Description                                 |
| ----------------- | ------------------------------------------- |
| `EMAIL`           | Their email address (required)              |
| `-r`, `--role`    | `viewer`, `contributor`, `admin` or `owner` |
| `-p`, `--project` | A workspace; without it, the organization   |
| `--json`          | The invitation, with its id                 |

### `qualia invitation withdraw`

Withdraw a pending invitation. Without `--project` it is found anywhere in the
organization; a workspace key passes its own workspace.

```bash
qualia invitation withdraw <INVITATION_ID> --yes
```

---

## `qualia dataset`

Wherever a command takes a dataset (`DATASET` or `--dataset`/`-d`, in
`qualia check`, `qualia mixture` and `qualia task link` too), name it by its id,
its full name (`accounts/<account>/datasets/<id>`) or its slug: the last part of
its dashboard address, `/datasets/<slug>`, and `slug` in
`qualia dataset list --json`. The examples below write `<DATASET_UUID>`; a slug
works in its place. A slug costs one extra request, and one no dataset has is
refused with a pointer to `qualia dataset list`.

The short id in the `Dataset ID` column of `qualia dataset list`, the first 8
characters of the id, names a dataset too, and so does any longer start of the
id, while exactly one dataset of the workspace begins with it. If several do,
or it is also another dataset's slug, the command lists them and stops. A
short id is found by reading the workspace's dataset list, one request for each
500 datasets, and one more for the slug it may be, so a script that names a
dataset often should pass the slug or the whole id.

Two commands take the full id only: `qualia dataset delete` (or the full name)
and `qualia dataset define --dataset-id`.

### `qualia dataset list`

List the datasets in your account, by name: uploads and merged datasets, or
the kind you ask for. It reads the qualia.v1 Datasets API in the API key's own
account. An older qualia-sdk with no
account set falls back to the older list route (uploads only).

```bash
qualia dataset list
qualia dataset list --search cube --dataset-type lerobot_v3
qualia dataset list --method umi --method teleop
qualia dataset list --tag gello --tag warehouse-a
qualia dataset list --kind mixture
qualia dataset list --kind all --filter 'merged_dataset = "accounts/<a>/datasets/<id>"'
qualia dataset list --limit 50 --json
```

| Option           | Description                                                          |
| ---------------- | -------------------------------------------------------------------- |
| `--limit`, `-n`  | Page size, 1–100 (default 20)                                        |
| `--cursor`       | Pagination cursor printed by the previous page                       |
| `--search`, `-q` | Only names containing this text                                      |
| `--dataset-type` | Filter: `lerobot_v2_1`, `lerobot_v3`, or `mcap_ros2`                 |
| `--method`       | Any canonical tag; repeat to match alternatives                      |
| `--tag`          | Any collection tag; repeat to match alternatives                     |
| `--kind`         | `upload`, `merged`, `mixture` or `all` (default: uploads and merged) |
| `--filter`       | An AIP-160 filter, ANDed on (`state`, `labels.*`, `target_robot`, …) |

`--filter 'merged_dataset = "<name>"'` lists the uploads a merged dataset holds,
which the list otherwise leaves out.

`--dataset-type` filters each page after it arrives, so
a page can be shorter than `--limit`; follow the printed cursor to read on.

The `Name` column names each dataset as the dashboard does; a long name continues
on the next line. On the older route (an old qualia-sdk) there are no names, so it
shows the repository's. `--json` also gives the repository (`acct-<uuid>/<name>`).

The `Dataset ID` column shows the first 8 characters of each id. Every dataset
command takes that short id, as it takes the whole id, the full name and the
slug that `--json` prints (see [`qualia dataset`](#qualia-dataset)).

The `Verdict` column says what each upload amounted to: `published`,
`degraded` (episodes were derived, but part of the upload was flagged) or
`underivable` (no episode could be derived; the raw bytes are kept). Datasets
published before verdicts existed show `published`. `--json` prints what the
call returned: qualia.v1 Datasets with `verdict` and `verdict_reason`; on the
older route, the older items with `qds.ingest_verdict` and
`qds.verdict_reason`.

### `qualia dataset tags`

Edit this dataset's collection metadata and custom tags. The recognized names are
`teleop`, `vr`, `umi`, `egocentric`, `policy-rollout`, `sim`, `real-world`,
`human` and `gello`; your own tags are accepted too. The set holds at most
64 distinct tags. A merged dataset changes only its own set, and an append
preserves it. Version tags and episode curation tags are separate.

```bash
qualia dataset tags <dataset-uuid> --tag umi --tag real-world --tag human --tag gello
qualia dataset tags <dataset-uuid> --tag warehouse-a
qualia dataset tags <dataset-uuid> --clear
```

A changed, nonempty classification set updates saved metadata and customs in one
atomic write. Custom-only input, or unchanged classifications with edited customs,
replaces only customs and preserves declarations and inference. When classifications
are present, echo them to remove customs without declaring inferred methods. `--clear`
clears customs and explicitly makes every collection dimension Unknown, so inference
cannot refill it. Omitting both `--tag` and `--clear` is an error.
The command needs an account-scoped API connection and independent collection metadata
support. The SDK reads the owner and uses its etag unless `--etag` was supplied;
a stale caller etag is refused.

### `qualia dataset methods`

Replace the six-name compatibility view on an upload or merged dataset.
A merged dataset changes only its own set. Other collection dimensions and
free tags remain; use `qualia dataset tags` for the other dimensions and customs.
The six tags are `teleop`, `vr`, `umi`, `egocentric`, `policy-rollout` and
`sim`. Use `--clear` to explicitly record an empty set (Unknown); omitting all
`--method` options is an error.

```bash
qualia dataset methods <dataset-uuid> --method umi --method teleop
qualia dataset methods <dataset-uuid> --clear
```

---

### `qualia dataset uploads`

Show the account's in-flight uploads, the feed the dashboard's pending band
renders, from any machine, including one that is not running the upload.

```bash
qualia dataset uploads
qualia dataset uploads --watch --every 30
qualia dataset uploads --json
```

| Option          | Description                                           |
| --------------- | ----------------------------------------------------- |
| `--watch`, `-w` | Refresh until Ctrl-C                                  |
| `--every`       | Seconds between refreshes (default 15, never under 5) |

Files and Bytes count content the lake has already accepted. Bytes go straight
to storage, so a first upload of new content reads 0 until it publishes; Phase
and Detail track the run throughout. Stop one with
[`cancel-upload`](#qualia-dataset-cancel-upload), and clear a failed one with
[`dismiss-upload`](#qualia-dataset-dismiss-upload).

---

### `qualia dataset upload`

Publish a local dataset directory as a pinned DatasetVersion. Bytes go straight
to storage; Qualia verifies the manifest and publishes the two together.
Episodes become visible only once that publication succeeds.

```bash
qualia dataset upload ./my-recording my-dataset
qualia dataset upload ./teleop-session teleop-1026 --source-format mcap_ros2
qualia dataset upload ./umi-session umi-1026 --source-format mcap_ros2 --rig "Kitchen UMI"
```

It prints the DatasetVersion id it published and the version a download takes,
`v0` for a fresh upload: `Download it with: qualia dataset download my-dataset:v0`
(`reference` in `--json`).

Where uploads are prepared, the receipt then names the next step in a line
that starts `Next:`. Preparation finds the tasks in the upload and links its
episodes to them: wait for it and read what it found with
[`qualia dataset preparation <DATASET_UUID> --wait`](#qualia-dataset-preparation)
(`next_step` in `--json`, with its `command` and `message`). Until preparation
has ended, `qualia check estimate` answers that no episode is linked.
The wait hint also appears when automatic paid video checks are off or their
policy cannot be read.

Where uploads are prepared **and automatic paid video checks are enabled**, the
receipt says the VLM check may run after preparation if the setting remains on.
Its bound is set only once preparation has linked the takes to Tasks, so the
receipt names `qualia check estimate <DATASET_UUID>` rather than a number
(`automatic_check` in `--json`, with `credits` null). With video checks off,
no preparation or an unreadable policy, the line is left out. Automatic-check
eligibility is saved with the published upload; a later opt-in does not enable
paid checks for that upload. Before a paid run starts, the current policy is
checked again, so a later opt-out prevents its admission. Change the setting
with [`qualia check policy-set`](#qualia-check-policy-and-policy-set).

`--source-format` takes `lerobot_v2_1`, `lerobot_v3`, or `mcap_ros2`. Leave it
out and the format is read from the directory: `codebase_version` in
`meta/info.json` tells LeRobot 2.1 from v3, and any `.mcap` makes it a session.
LeRobot 2.1 is never aliased or converted to v3, so its source provenance stays
distinct.

Use `--source-format mcap_ros2` for a ROS 2 bag session. Lay it out as one
subdirectory per recording, each holding its `.mcap` bags and, optionally, an
`episode.json` sidecar carrying `task` and `mode` fallbacks. Every `.mcap` is
ingested on its own, so rosbag2 split files (`bag_0.mcap`, `bag_1.mcap`, …) are
accepted; see [ROS 2 MCAP](https://junction-docs.qualiastudios.dev/global/guides/data-collection.md#ros-2-mcap).

An MCAP session may carry `meta/ingest.toml` beside its bags, at the root of
the directory you upload, to declare its state and action topics, its clock and
its episode boundaries. Most teleoperated arms need none. See
[Declaring state and action channels](https://junction-docs.qualiastudios.dev/global/guides/data-collection.md#declaring-state-and-action-channels)
and
[Declaring episode boundaries](https://junction-docs.qualiastudios.dev/global/guides/data-collection.md#declaring-episode-boundaries).

Use `--rig` for a handheld (UMI) recording. It names the UMI rig the recording was
made on: its name or id (the old name `umi-handheld` is the rig that holds that
key, else the workspace's one UMI rig). A recording whose `meta/ingest.toml`
declares `[rig] kind = "umi-handheld"` and names no rig takes the workspace's one
UMI rig; none or several refuses it. A workspace has the UMI rigs someone made and none otherwise, so
make one first with **New rig, UMI** in the dashboard or
[`qualia gripper umi-rig`](#qualia-gripper-umi-rig) and calibrate it; naming one that does not exist is refused before anything is
stored. The server writes that rig's kind and TCP calibration into the upload's
`meta/ingest.toml`, so the recording converts to gripper poses as it lands. Your
local file is never written. See
[Handheld (UMI) recordings](https://junction-docs.qualiastudios.dev/global/guides/data-collection.md#handheld-umi-recordings).

Each upload is its own dataset unless you say otherwise. `--merge-target`
appends this one upload to an existing merged dataset; pass its id, its slug or
its full resource name (`accounts/{account}/datasets/{id}`). A merged dataset
is made with [`qualia dataset merge`](#qualia-dataset-merge) and is listed as
`merged` by `qualia dataset list`. To append every upload to the same merged
dataset, save it once with
[`qualia dataset destination set`](#qualia-dataset-destination); each upload
then prints `Upload destination: append to …` on stderr before it starts, and
`Appended upload to …` once it has published (`merge_append` in `--json`).
`--standalone` keeps one upload on its own even when a destination is saved.
`--merge-target` and `--standalone` cannot be combined.

One dataset that grows with every batch takes three steps. A merge takes at
least two uploads, so on day one there is nothing to merge yet:

```bash
qualia dataset upload ./day-1 day-1 --standalone   # day one
qualia dataset upload ./day-2 day-2 --standalone   # day two, then merge the two once
qualia dataset merge day-1 day-2 --name "Bench"    # final; try it with --dry-run first
qualia dataset upload ./day-3 day-3 --merge-target <MERGED_DATASET>   # from then on
```

| Option                | Default              | Description                                                                                                                                                                   |
| --------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--source-format`     | detected             | Source layout: `lerobot_v2_1`, `lerobot_v3` or `mcap_ros2`; detected from the directory when omitted                                                                          |
| `--upload-workers`    | half your cores, 4–8 | Large (over 64 MiB) or edited files read and uploaded at once                                                                                                                 |
| `--chunk-threads`     | ≈ half your cores    | Threads used to hash and split files                                                                                                                                          |
| `--quiet`             | off                  | Silence the live per-phase progress on stderr                                                                                                                                 |
| `--merge-target`      | saved destination    | Append this upload to this merged dataset (id, full name or slug), for this run only                                                                                          |
| `--standalone`        | off                  | Keep this upload its own dataset even when a destination is saved                                                                                                             |
| `--rig`               | none                 | Convert a handheld (UMI) recording at this UMI rig as it lands: its name or id (the old name `umi-handheld` is the rig that holds that key, else the workspace's one UMI rig) |
| `--allow-underivable` | off                  | Upload an MCAP session even when no bag in it can be derived; a LeRobot structure refusal cannot be lifted                                                                    |
| `--json`              | off                  | Print the receipt as JSON on stdout (see the exit codes below)                                                                                                                |

Before a byte moves, an `mcap_ros2` upload checks every bag for damage the
server can never derive episodes from: a file too small to be MCAP, a bad
leading magic, a cut before its first complete data record, or a record whose
framing runs past the file. A well-formed bag with no messages is treated the
same way. A damaged or empty bag does not stop the upload: it is listed, with
the byte offset where it is known, and uploads as recorded; ingest reports it as
`source_underivable` and the rest of the session publishes.

A bag cut after the recording started is listed as `source_truncated` when the
part before the cut holds robot state, and the server derives the part that was
recorded. By default one `JointState` message is enough. When
`meta/ingest.toml` declares `[state].topics`, every declared topic needs a
message before the cut; one missing topic makes the bag `source_underivable`
(`no_state`), as does a cut bag with camera frames but no state. When the
client cannot open the bag's compressed chunks (lz4, zstd before Python 3.14,
or a chunk too large to inspect), or cannot parse `meta/ingest.toml`, it lists
the bag as `source_truncated` and the server decides; the server may still find
no state and report `no_state`.

Only a session the server can derive nothing from, with no `.mcap` at all or
every bag damaged or empty, stops the upload with exit code 1:

```text
BLOCKED — 2 file(s) the server cannot derive; nothing was uploaded:
  ./session/episode-0/recording.mcap: truncated MCAP file (missing the trailing magic) — the recording did not finish [source_underivable]
  ./session/episode-1/recording.mcap: truncated MCAP file (missing the trailing magic) — the recording did not finish [source_underivable]
  ./session: every bag is underivable — no episode can be derived [session_no_episodes]
Re-run with --allow-underivable to upload the bytes as recorded.
```

With `--allow-underivable` the bytes upload anyway and are kept as a source
dataset with no episodes. The receipt (`--json`) carries the check as
`preflight`.

A LeRobot upload (`lerobot_v2_1` or `lerobot_v3`) is checked the same way. The
server refuses some structure outright, so these stop the upload with exit
code 1, all listed in one run:

| Check                                                                                                                                                                                                                                   | Code                        |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `meta/info.json` missing or not a JSON object; `fps` not in (0, 1000]; `total_tasks` not a positive integer; no `features` object; a `video_path` placeholder the server cannot render; `lerobot_v2_1` without a positive `chunks_size` | `info_json_unreadable`      |
| `codebase_version` is not `v2.1` for `lerobot_v2_1` or `v3.0` for `lerobot_v3`                                                                                                                                                          | `codebase_version_mismatch` |
| no `*.parquet` file under `data/`                                                                                                                                                                                                       | `data_shards_missing`       |
| `lerobot_v2_1` without `meta/episodes.jsonl` or `meta/tasks.jsonl`                                                                                                                                                                      | `info_json_unreadable`      |

`--allow-underivable` cannot override these: the server refuses the structure
whatever the flag says, so the upload stops with exit code 1 either way.

A camera whose video rate differs from the state and action row rate is accepted and flagged as `video_rate_differs_from_rows`; the viewer aligns them by time, but LeRobot download and fine-tuning require re-exporting the video at the row rate and uploading again.

A missing video does not stop the upload, and nothing is repaired for you.
Paths come from `video_path` in `meta/info.json`, or the server's default
`videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4` when it
has none. For `lerobot_v2_1` every episode's video is checked and each missing
one is listed with its episode and camera; for `lerobot_v3` each camera needs at
least one file matching the template, and a camera with none is listed as
missing from every episode. Both use `camera_absent_for_episode`: the upload
continues, those episodes will be flagged, and you should probably exclude
them. With no video for any camera, one warning says every episode will be
flagged.

Every completed upload ends with a verdict, `ingest_verdict` in the `--json` receipt
(with `verdict_reason` and `verdict_source`), and an exit code a script can
branch on:

| Exit | Verdict                   | Meaning                                                                                                     |
| ---- | ------------------------- | ----------------------------------------------------------------------------------------------------------- |
| `0`  | `published` or `degraded` | The dataset published; `degraded` means an ingest finding, a flagged episode or an unreadable ingest report |
| `1`  | none                      | It failed or was refused: a pre-flight block or an error                                                    |
| `3`  | `underivable`             | The bytes are stored as a source dataset, but no episode could be derived                                   |

An underivable upload ends with one line on stderr naming the dataset that
keeps the bytes and the reason:

```text
UNDERIVABLE — no episodes could be derived; the bytes are kept as dataset <ID>. Reason: session_no_episodes: …
```

With `--json`, stdout carries the full receipt for a completed upload (exit 0
or 3) and `{"preflight": …}` for a pre-flight block (exit 1). Any other error
prints nothing on stdout and exits 1 with its message on stderr.

Exit 1 does not mean nothing is left. A pre-flight block moves nothing, but
after a transfer began the upload stays in
[`qualia dataset uploads`](#qualia-dataset-uploads) as `failed`, with its bytes.

Progress is written to stderr, so `--json` output stays pipeable. The server-side
phases are bounded by silence rather than elapsed time: ingest parses every bag
and transcodes every camera, so a large upload is free to take as long as it
takes. See [Configuration](https://junction-docs.qualiastudios.dev/sdk/configuration.md) for the two environment variables
that govern that wait.

---

### `qualia dataset destination`

A destination is a merged dataset that later uploads append to by default, so
you do not pass `--merge-target` every time. It is saved in this CLI's config
file, bound to the API host, account and workspace it was saved under. An
upload made under another host, account or workspace ignores it, stays
standalone and says so on stderr.

```bash
qualia dataset destination set accounts/<account>/datasets/<id>
qualia dataset destination show
qualia dataset destination clear
```

| Command | What it does                                                                                                             |
| ------- | ------------------------------------------------------------------------------------------------------------------------ |
| `set`   | Save `TARGET`, an existing merged dataset (its id, full name or slug), as the default `--merge-target` for later uploads |
| `show`  | Print the saved destination with its API host, account and workspace; `--json` prints them as JSON                       |
| `clear` | Remove the saved destination; later uploads are standalone again                                                         |

`set` refuses a dataset that is not merged. A single upload can still go
elsewhere with `--merge-target`, or stay on its own with `--standalone`.

---

### `qualia dataset ingest-report`

Show which cameras a source dataset publishes and every degradation the ingest
performed instead of failing. A camera missing from one bag is dropped from the
whole dataset, so this is where a two-camera pin of a three-camera recording is
explained. It opens with the upload's report card: how many episodes were
accepted, and which were flagged and why.

```bash
qualia dataset ingest-report <DATASET>
qualia dataset ingest-report <DATASET> --json
qualia dataset ingest-report acct-<ACCOUNT_UUID>/<NAME> --ref <COMMIT>
```

| Argument / option | Description                                                                    |
| ----------------- | ------------------------------------------------------------------------------ |
| `DATASET`         | Dataset id, full name or slug; or a canonical path `acct-<uuid>/<name>`        |
| `--ref`           | With a path only: branch, tag or commit to read the report from (default: tip) |

By id, name or slug the report is read through the qualia.v1 Datasets API; a
merged dataset answers its uploads' reports folded into one, and `--json` prints
that API's `IngestReport`. A path reads the older route. A report too large to
read inline is an error, never shown as clean.

---

### `qualia dataset download`

Download a dataset version in a format, or every file uploaded to the dataset,
byte for byte (`--format original`).

```bash
# the newest version (stable, or v0 when nothing is tagged yet), as LeRobot v3
qualia dataset download my-dataset

# one version, in each format
qualia dataset download my-dataset:1.0
qualia dataset download my-dataset:1.0 --format lerobot_v2_1
qualia dataset download my-dataset:1.0 --format mcap_ros2

# files to upload to Physical Intelligence or Dream Labs
qualia dataset download my-dataset:1.0 --format pi_mcap_v1
qualia dataset download my-dataset:1.0 --format dreamlabs_v2_1

# every file uploaded, byte for byte
qualia dataset download my-dataset --format original
```

`DATASET` is the dataset's slug, id or short id; add `:<version>` for a tag, `stable`
(the newest tag), `v0` (an upload as it arrived), a DatasetVersion id such as
the one `qualia dataset upload` prints, or a prefix of a digest or id. A full
`accounts/<account>/datasets/<dataset>/versions/<tag>` name also works. Without
a version you get the newest one: `stable`, or `v0` for an upload with no tag
yet, and the command says which it took.

`DEST` defaults to `./<slug>-<tag>`, with `-v21` for LeRobot v2.1, `-mcap`
for MCAP, `-pi` for `pi_mcap_v1` and `-dreamlabs` for `dreamlabs_v2_1`, and to
`./<slug>-original` for `original`.

| Argument / option | Description                                                                                                                      |
| ----------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `DATASET`         | `<dataset>[:<version>]`, or a full version name                                                                                  |
| `DEST`            | Destination folder (optional)                                                                                                    |
| `--format`        | `lerobot_v3` (default), `lerobot_v2_1`, `mcap_ros2`, `pi_mcap_v1`, `dreamlabs_v2_1` or `original`; dashes work too (`mcap-ros2`) |
| `--json`          | Print the result as JSON                                                                                                         |

`pi_mcap_v1` and `dreamlabs_v2_1` are files to upload to Physical Intelligence
or Dream Labs with your own account: the version's episodes that are not
rejected (Dream Labs: only the successful ones), with a `README.md` that says
how to upload them (and `upload_to_pi.py` for Pi). An episode that could not be
converted is listed there with its error. See
[Partner layouts](https://junction-docs.qualiastudios.dev/global/guides/download.md#partner-layouts).

`original` is every file uploaded to the dataset, byte for byte, with no version
involved. It also holds what ingest stored beside those files: the annotations
under `meta/qds`, `meta/ingest_report.json` and, for an MCAP upload, the LeRobot
data and videos ingest made from the recordings. Trims and splits are not
applied: the takes are whole, and a split's pieces are only annotations under
`meta/qds`. When the dataset has any, the command says how many on stderr and
how to get the curated episodes (tag a version, then download it). Only an
upload has files of its own, so a merged dataset or a mixture is refused:
download one of its uploads, or a version. For now `original` needs an
organization key; a workspace key is refused.

An MCAP download keeps only what was mapped at upload: `tf`, diagnostics and
custom topics do not come back, so use `original` when you need them.

While the server builds a version (or, for `original`, lists the files) you
see a spinner on stderr, then a bar with bytes, speed and `files n/m`. Files are checked as they arrive, and a run that
stops keeps the files it checked: run the same command again and it fetches
only the rest. Nothing is drawn with `--json`.

It ends with
`Saved <version> as <format> to <DEST> (<N> files, <size>)`, plus
`; <K> kept from an earlier run` when files were reused.

`--json` prints `destination`, `format`, `reference`, `version`,
`dataset_version_id`, `file_count`, `byte_count`, `downloaded_file_count`,
`reused_file_count` and `materialization` (the qualia.v1 Materialization). For
`original`, `reference` is the dataset's slug and `version`,
`dataset_version_id` and `materialization` are `null`.

The first download of a version in a format starts its rendering, which needs
a key that can write to the dataset (`data:write`); once it is rendered,
downloads of it in that format need read access only (`data:read`). `head`,
what a dataset yields right now, has no download: tag it first.

A version that contains a blocked episode (a NaN, an infinity, a missing value
or a wrong width in its state or action rows) is refused, because a download
carries every episode. Publish a curated version without them first, with
`qualia dataset curate <DATASET_VERSION_UUID> --idempotency-key <UUID> --name <NEW_DATASET>`,
and download that. `--format original` is not refused: it renders nothing, so it
downloads the upload's files, blocked episodes included.

The dashboard gives the command with the version filled in: **Download** on a
dataset or version page.

---

### `qualia dataset delete`

Delete a mixture, or retire an upload or a merged dataset, for example after
uploading the wrong folder. It leaves every list and every mixture, versions cut
from it keep resolving, and no storage is reclaimed. A merged dataset is retired
with the uploads it holds; the prompt says how many.

```bash
qualia dataset delete <FULL_DATASET_UUID>
qualia dataset delete accounts/<account>/datasets/<id> --force --yes
qualia dataset delete <FULL_DEFINITION_UUID> --definition --yes
```

The id must be the full UUID or the full name: `qualia dataset list --json`
shows it as `name` (`accounts/<account>/datasets/<id>`). It is refused while a
mixture reads the dataset, and while Tasks reference its episodes unless
`--force`. On the older route (an old qualia-sdk) it retires uploads only.

| Option         | Description                                                      |
| -------------- | ---------------------------------------------------------------- |
| `--force`      | Also remove the Task references to its episodes (the Tasks stay) |
| `--etag`       | Refuse if the dataset changed since this etag                    |
| `--definition` | Delete a dataset definition instead; datasets are untouched      |
| `--yes`, `-y`  | Skip the confirmation (required without a terminal)              |
| `--json`       | Print `name`, `displayName`, `kind` and `deleted`                |

---

### `qualia dataset import-hf`

Import one exact Hugging Face dataset commit server-side. The command confirms the pinned coordinates, submits them once, and
polls while Hub bytes stream straight into storage. Success creates a
pinned immutable DatasetVersion.

```bash
export HF_TOKEN="..." # optional for public datasets
qualia dataset import-hf \
  qualia-robotics/openarm-cube-in-box-v1 \
  17c29c487d886878ddcb3c5649101b2860fbc047 \
  cube-in-box \
  --source-format lerobot_v3
```

The revision must be lowercase 40-hex; branches, tags, URLs, and embedded
credentials are rejected. `HF_TOKEN` is read only from the environment and is
never accepted as a command-line option, printed, or sent in poll requests.
Without it, a private or gated repository is read with the workspace's Hugging
Face key from Settings → Integrations (`qualia integration add huggingface`);
with no key that can read it, the import fails and says which key to add.
Local file/byte/work-directory switches do not exist because no dataset body or
scratch file touches the CLI machine. `--json` requires `--yes` so stdout stays
valid JSON.

### `qualia dataset define`

Save a lake query as a dataset definition, a target `curate` can publish a
version into. `qualia dataset pin <DEFINITION_UUID>` freezes what it selects
as an immutable version.

```bash
qualia dataset define kitchen-2026-09-16 --dataset-id <SOURCE_DATASET_UUID>
qualia dataset define audited-only --query ./query.json --description "human audited"
```

| Argument / option | Description                                                      |
| ----------------- | ---------------------------------------------------------------- |
| `NAME`            | One path segment: letters, digits, `.`, `_`, `-` (required)      |
| `--dataset-id`    | Select episodes of this source dataset; repeatable               |
| `--task`          | Select one task                                                  |
| `--tag`           | Require this tag; repeatable                                     |
| `--query`         | JSON file holding the complete saved query, instead of the flags |
| `--description`   | Free text                                                        |

`--query` takes the shape of the SDK's `DatasetSavedQuery`: `dataset_ids`,
`task`, `tier`, `min_duration_s`, `max_duration_s`, `tags`, `review_state`,
`search`, `numeric_predicates`, `predicates`, `episodes`, `query_text`.

---

### `qualia dataset pin`

Freeze something as an immutable DatasetVersion. The printed DatasetVersion id
is what `training-features` and `curate` take, and what the dashboard's training
launcher trains on.

- `pin DATASET TAG` publishes a version of an upload or a merged dataset: the
  takes kept by a person (a key's keeps count), with the trims and splits saved
  on them. It first freezes and measures that selection, as
  [`version-preview`](#qualia-dataset-version-preview) does, and refuses to
  publish while it leaves a take out, unless `--allow-left-out`. A take that is
  only kept automatically is not taken: `--include kept` takes it.
  `--as-recorded` and `--digest` tag head instead: every kept episode as
  recorded, with saved trims and splits left out. A mixture always tags head.
  Tags never move; a tag name is taken once per dataset.
  Acts in the API key's own account (`--account` is optional).
- `pin DEFINITION_ID` freezes what a definition's saved query selects now. This
  stays on the `/v1` path, because saved queries have no qualia.v1 equivalent.

```bash
qualia dataset pin <DATASET_UUID> 1.0 --note "first cut"
qualia dataset pin <DATASET_UUID> 1.0 --preview <PREVIEW_NAME>
qualia dataset pin <DATASET_UUID> 1.1 --include kept --allow-left-out
qualia dataset pin <DATASET_UUID> recorded-1 --as-recorded
qualia dataset pin <DATASET_UUID> 1.2 --digest <HEAD_DIGEST_YOU_REVIEWED>
qualia dataset pin <DEFINITION_UUID> --note "first pin"
```

| Argument / option  | Description                                                                                                                             |
| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `TARGET`           | With `TAG`: dataset id, full name or slug. Without: a definition UUID                                                                   |
| `TAG`              | Tag name, e.g. `1.0`: 1-64 of `[0-9A-Za-z._-]`                                                                                          |
| `--note`           | What the version is                                                                                                                     |
| `--include`        | With `TAG`: which takes, by how they stand: `kept_by_person` (default), `kept`, `flagged`, `rejected`, `rejected_by_person`; repeatable |
| `--preview`        | With `TAG`: publish this frozen preview, by the name or id `version-preview` printed                                                    |
| `--as-recorded`    | With `TAG`: tag head, leaving saved trims and splits out                                                                                |
| `--digest`         | With `TAG`: tag head while it has the digest you reviewed; refused (409) if head moved                                                  |
| `--allow-left-out` | With `TAG`: publish although the preview leaves takes out                                                                               |
| `--json`           | JSON output                                                                                                                             |

`--include`, `--preview`, `--as-recorded` and `--digest` are mutually exclusive.
`--allow-left-out` goes with neither `--as-recorded` nor `--digest`, and none
of these goes with a definition.

---

### `qualia dataset version-preview`

Freeze and measure what a version would hold, and publish nothing: the takes
picked by how they stand, each with the trim or split saved on it. It prints
the preview's name, its episodes and duration, the takes it leaves out with why,
every window, and a summary of the measured metrics. Publish exactly that
preview with [`qualia dataset pin DATASET TAG --preview`](#qualia-dataset-pin).
An upload and a merged dataset have previews; a mixture tags head.

```bash
qualia dataset version-preview <DATASET_UUID>
qualia dataset version-preview my-dataset --include kept --include flagged
qualia dataset version-preview my-dataset --json > preview.json   # .preview.name names it
```

| Argument / option | Description                                                                                                                 |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug                                                                                               |
| `--include`       | Which takes, by how they stand: `kept_by_person` (default), `kept`, `flagged`, `rejected`, `rejected_by_person`; repeatable |
| `--json`          | `{"preview": {...}, "items": [...], "metrics": {...}}`: the preview, every window and the metric summaries                  |

A window names its take in `take`. Its `label` counts takes from 1, so
`episode 8` is the take whose `index` is 7. Each metric's count, mean, p5, p50
and p95 are over the measured windows only. You have one live preview of a
dataset: a new one replaces the last.

---

### `qualia dataset version list`

List a dataset's tags, newest first.

```bash
qualia dataset version list my-dataset
qualia dataset version list my-dataset --json
```

| Argument / option | Description                                    |
| ----------------- | ---------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug                  |
| `--filter`        | AIP-160 filter over `digest` and `create_time` |
| `--limit`, `-n`   | Page size, 1-500 (default 50)                  |
| `--cursor`        | Next-page token                                |
| `--json`          | `{"versions": [...], "nextPageToken": ...}`    |

---

### `qualia dataset version get`

Show one version: its tag, reference, digest, episode counts, DatasetVersion id
and note.

```bash
qualia dataset version get my-dataset:1.0
qualia dataset version get stable --dataset my-dataset --json
```

`VERSION` is `<dataset>:<tag>` (slug or UUID; a tag, `stable`, `head` or a digest
prefix), a full version name, or a tag with `--dataset`.

---

### `qualia dataset version diff`

What came in, went out or changed revision between two refs, and why. It also
prints the note and the tag the dashboard's tag dialog would offer.

```bash
qualia dataset version diff my-dataset                       # newest tag → head
qualia dataset version diff my-dataset --base 1.0 --target 1.1 --json
```

| Option          | Description                                                |
| --------------- | ---------------------------------------------------------- |
| `--base`        | A tag, `stable` or a digest prefix; default the newest tag |
| `--target`      | Default `head`                                             |
| `--limit`, `-n` | Changed episodes per page, 1-1000 (default 100)            |
| `--cursor`      | Next-page token                                            |

---

### `qualia dataset version create`

Publish a version. It does what
[`qualia dataset pin DATASET TAG`](#qualia-dataset-pin) does, with the same
options, and also takes the dataset's slug: by default the takes kept by a
person, with the trims and splits saved on them; `--as-recorded` or `--digest`
to tag head. A mixture always tags head. The tag never moves.

```bash
qualia dataset version create my-dataset 1.0 --note "first cut"
qualia dataset version diff my-mixture --json      # read targetDigest
qualia dataset version create my-mixture 1.1 --note "dropped 3 jerky takes" --digest b3:…
```

`--digest` is the head digest you reviewed; if head has moved since, the tag is
refused (409).

---

### `qualia dataset version note`

Edit a version's note, the one thing about a tag that changes.

```bash
qualia dataset version note my-dataset:1.0 "first cut, 120 takes"
```

`--etag` refuses the edit if the note changed since you read it.

---

### `qualia dataset version merges`

A merged dataset's merges, newest first, and the uploads each took. A merge is
final: none can be reverted.

```bash
qualia dataset version merges my-merged-dataset --json
```

---

### `qualia dataset version record`

What was done to the dataset in the round a version opened: from its creation
to the next tag's (for `head`, since the newest tag). It lists rulings, edits,
splits and issues, each with who did it and when, as the dashboard's
**Curation of 1.0** panel does. A merged dataset's record also reads the uploads
it holds. At most 2000 events are read; a longer round shows its newest.

```bash
qualia dataset version record my-dataset:1.0
qualia dataset version record my-dataset:head --json
```

It reads the events of the dataset's workspace.

---

### `qualia dataset curation-suggest`

Explain bad-data recommendations for immutable DatasetVersions without
mutating them. Use `--page-token` to continue the result set.

```bash
qualia dataset curation-suggest <DATASET_VERSION_UUID>
qualia dataset curation-suggest <DATASET_VERSION_UUID> --json
```

### `qualia dataset curate`

Publish the reviewed analysis's sealed recommendation set as a new immutable
DatasetVersion. Supply a stable idempotency UUID. Source bytes and Git history
are not deleted. Retrying with the same idempotency key resumes or recovers
that publication's receipt instead of suggesting fresh evidence against a
head the first attempt already moved. A failed publication keeps its key:
publish again under a new one.

```bash
qualia dataset curate <DATASET_VERSION_UUID> \
  --definition-id <DATASET_DEFINITION_UUID> \
  --idempotency-key <OPERATION_UUID> \
  --yes
```

Omit `--yes` for an interactive confirmation.

Pass `--name <NEW_DATASET>` instead of `--definition-id` to publish the curated
version as a new dataset. That is the form to use on an upload: an upload's own
dataset is source-owned and refuses a curated version. The curated version
leaves out every episode the analysis recommends removing, not only blocked
ones.

---

### `qualia dataset training-features`

Show the exact camera, stream, timebase, and frame-timestamp
descriptors for one immutable DatasetVersion. `VERSION` is `<dataset>:<tag>`, a
DatasetVersion UUID, or a full version name. The output includes the resolved id; head must be pinned first.

```bash
qualia dataset training-features my-dataset:1.0
qualia dataset training-features my-dataset:1.0 --json
```

| Argument  | Description                                                                                    |
| --------- | ---------------------------------------------------------------------------------------------- |
| `VERSION` | `<dataset>:<tag>` (slug or dataset UUID), DatasetVersion UUID, or full version name (required) |

---

### `qualia dataset markers`

Show the recorder markers inside one episode, in time order, in seconds from
the start of the recording. Every edit command below cuts on these labels, so
this is how you find out what a recording actually wrote.

The episode commands below speak qualia.v1, in the API key's own account, and they
name episodes through the upload they were recorded in: `--dataset`/`-d` with
bare episode ids, or full names `accounts/…/datasets/…/episodes/…`.

```bash
qualia dataset markers <EPISODE_UUID> -d <DATASET_UUID>
qualia dataset markers <EPISODE_UUID> -d <DATASET_UUID> --json
```

---

### `qualia dataset split`

Cut episodes into children at their own markers, at given instants, or around
windows to keep. Children are new episodes and the parents are left as they were,
but a split is published when it answers and cannot be undone: `dataset unsplit`
removes only children that were never published. Preview with `--dry-run`; once
split, curate a child by trimming or rejecting it.

```bash
qualia dataset split <EPISODE_UUID> -d <DATASET_UUID> --cut-at grasp
qualia dataset split <EPISODE_UUID> <EPISODE_UUID> -d <DATASET_UUID> --cut-at grasp --dry-run
qualia dataset split <EPISODE_UUID> -d <DATASET_UUID> --at 12.5 --at 31
qualia dataset split <EPISODE_UUID> -d <DATASET_UUID> --keep 0.5-6 --keep 8-14 --dry-run
```

| Option             | Description                                                      |
| ------------------ | ---------------------------------------------------------------- |
| `--dataset`, `-d`  | The upload the episodes were recorded in (required)              |
| `--cut-at`         | Marker label to cut at, as the recording wrote it                |
| `--at`             | Cut at this instant, in seconds from the start of the recording  |
| `--keep`           | Keep this window as one child, `START-END` seconds, e.g. `1.5-9` |
| `--start-at-first` | Drop the lead-in, up to the FIRST marker so labelled             |
| `--end-at-last`    | Drop the run-out, after the LAST marker so labelled              |
| `--dry-run`        | Plan the cuts without writing them                               |
| `--json`           | JSON output                                                      |

A request cuts one way: at markers, at instants or around windows. For markers,
name at least one label across `--cut-at`, `--start-at-first` or
`--end-at-last`. The narrowing options apply before the cut, which is how a
lead-in is dropped; given alone, each episode becomes one trimmed child. All
three accept repeats.

`--at` is for takes the recorder marked nothing at. Repeat it for several cuts;
every named episode is cut at the same instants. It cannot be combined with the
marker options. To find the instants, watch the take (`dataset videos`) or read
it with the SDK's `episodes.sample_series`.

`--keep` makes one child of each window and drops what lies outside every
window: the lead-in, the run-out and the stretches between. Repeat it for
several windows, in time order and not overlapping; every named episode keeps
the same windows. It cannot be combined with `--at` or the marker options. The
summary counts the frames no child keeps.

A trimmed episode is cut inside its trim: its children keep only the frames
the trim keeps, what the trim cut is counted as dropped, and a marker or instant
there cuts nothing.

An episode with no matching cut, or with children already, is left alone.

---

### `qualia dataset unsplit`

Delete the children of split episodes that were never published: those a split
made before splits published. A published split is refused; curate its children
by trimming or rejecting them.

```bash
qualia dataset unsplit <PARENT_EPISODE_UUID> -d <DATASET_UUID>
```

---

### `qualia dataset trim`

Trim episodes to their own markers, or to given seconds, reversibly. Each
episode is narrowed to start at ITS first named marker and end at ITS last.
The base extent never changes, so `--clear` restores it.

```bash
qualia dataset trim <EPISODE_UUID> -d <DATASET_UUID> --start-at-first ready --end-at-last done
qualia dataset trim <EPISODE_UUID> -d <DATASET_UUID> --start 1.5 --end 12
qualia dataset trim <EPISODE_UUID> -d <DATASET_UUID> --clear
```

| Option             | Description                                                      |
| ------------------ | ---------------------------------------------------------------- |
| `--dataset`, `-d`  | The upload the episodes were recorded in                         |
| `--start-at-first` | Trim the lead up to the FIRST marker so labelled                 |
| `--end-at-last`    | Trim the tail after the LAST marker so labelled                  |
| `--start`          | Start each episode here, seconds from the start of the recording |
| `--end`            | End each episode here, seconds from the start of the recording   |
| `--clear`          | Restore the full extent instead of trimming                      |
| `--json`           | JSON output                                                      |

Use one way per call: marker labels, `--start`/`--end`, or `--clear`.
`--start` or `--end` alone moves that end and keeps the episode's other one
(the whole recording when it was untrimmed). Each episode is read first and
written with the etag it read, so an edit made in between is refused rather than
overwritten. An episode the server will not trim, such as a split parent, is left
alone with its reason.

---

### `qualia dataset subtasks`

Name each episode's subtask spans from its own markers — the same boundary rule
`dataset split` cuts on, used to label a span instead of carving it, so a whole
selection can be named at once.

```bash
qualia dataset subtasks <EPISODE_UUID> -d <DATASET_UUID> --at grasp --at place
qualia dataset subtasks <EPISODE_UUID> -d <DATASET_UUID> --at grasp --starts-at
```

| Option            | Description                                                    |
| ----------------- | -------------------------------------------------------------- |
| `--dataset`, `-d` | The upload the episodes were recorded in (required)            |
| `--at`            | Marker label whose presses bound a subtask (required, repeats) |
| `--starts-at`     | Each marker OPENS the span it names (default: it closes it)    |

---

### `qualia dataset edit`

Edit many episodes in one call: all of them change or none do, one revision
each. Name the episodes and the fields they all take, or pass `--from` with a
JSON list of per-episode edits (`episode` plus the fields to change, and an
`etag` to refuse the call if that episode changed since you read it). Up to 500
episodes of one upload per call. Trim with `dataset trim` and link tasks with
`dataset assign-task` or `dataset link-episode`.

```bash
qualia dataset edit <EPISODE_UUID>... -d <DATASET_UUID> --tag slow --outcome FAILURE
qualia dataset edit -d <DATASET_UUID> --from edits.json
```

| Option            | Description                                              |
| ----------------- | -------------------------------------------------------- |
| `--dataset`, `-d` | The dataset the episodes are read through (required)     |
| `--task`          | Task label                                               |
| `--instruction`   | Instruction                                              |
| `--scene`         | Scene                                                    |
| `--tag`           | Replace the tags with these (repeats)                    |
| `--clear-tags`    | Remove every tag                                         |
| `--outcome`       | `SUCCESS`, `PARTIAL`, `FAILURE` or `OUTCOME_UNSPECIFIED` |
| `--from`          | JSON list of per-episode edits; `-` reads stdin          |

---

### `qualia dataset assign-task`

Label episodes with a task and link them to it. In an upload it changes all of
them or none. On a merged dataset it is all or none per upload: each upload
commits on its own, so a failed call can leave earlier uploads changed. Rerun it
to finish. With `--task`, every selected episode links to that Task and, without
`--label`, takes its display name as the label: the language instruction
training reads. A label alone sets the text and leaves links as they are.

Use it when the episodes need the label as well as the link. To only say which
Task an upload was recorded against, use `link-task`.

```bash
qualia dataset assign-task -d <DATASET_UUID> --task <TASK_UUID> --filter 'state = KEPT'
qualia dataset assign-task <EPISODE_UUID> -d <DATASET_UUID> --label "restock shelf"
```

| Option            | Description                                         |
| ----------------- | --------------------------------------------------- |
| `--dataset`, `-d` | The upload the episodes were recorded in (required) |
| `--task`, `-t`    | Task to link: id or `accounts/.../tasks/...`        |
| `--label`         | The label; defaults to the task's display name      |
| `--filter`        | Select episodes by filter instead of naming them    |
| `--json`          | JSON output                                         |

Pass `--task`, `--label` or both. Name episodes or pass `--filter`, not both.

---

### `qualia dataset link-task`

Set the Task an upload was recorded against. Every episode with no link of its
own reads it, in one request. On a merged dataset it is set on every upload the
dataset holds. A link, not a label: `labels.task` stays as it is, and a mixture
can follow every upload linked to the task.

Episodes that link the old Task themselves keep it. Pass `--relink` to move them
too.

```bash
qualia dataset link-task <DATASET_UUID> <TASK_UUID>
qualia dataset link-task <DATASET_UUID> <TASK_UUID> --relink
qualia dataset link-task <DATASET_UUID> --unlink
```

| Option     | Description                                                     |
| ---------- | --------------------------------------------------------------- |
| `--unlink` | Remove the upload's task link (omit the task)                   |
| `--relink` | Also move the episodes linked to the old task themselves        |
| `--etag`   | The dataset's etag as read; refuse the change if it moved since |
| `--json`   | JSON output                                                     |

---

### `qualia dataset link-episode`

Link one episode to a Task, or remove its own link. A link, not an edit: the
episode's task label, revisions and versions stay as they are. Unlinked, the
episode reads the Task of the dataset that holds it again. To label and link many
episodes, use `assign-task`; to link a whole upload, `link-task`.

```bash
qualia dataset link-episode <EPISODE_UUID> -d <DATASET_UUID> --task <TASK_UUID>
qualia dataset link-episode accounts/…/datasets/…/episodes/… --unlink --json
```

| Option            | Description                                                     |
| ----------------- | --------------------------------------------------------------- |
| `--dataset`, `-d` | The dataset the episode is read through (with a bare id)        |
| `--task`, `-t`    | Task to link: id or `accounts/.../tasks/...`                    |
| `--unlink`        | Remove the episode's own link                                   |
| `--etag`          | The episode's etag as read; refuse the link if it changed since |
| `--json`          | JSON output                                                     |

Pass `--task` or `--unlink`, one of them.

---

### `qualia dataset preparation`

What the newest prepare run did to an upload, as the dataset page's **Tasks**
tab shows it: its state, error and warnings; the tasks discovery found, each
with its episode count, health and the Task its episodes are checked against
(an existing Task or a new draft); and the dataset's own task names, with their
keys and the decisions on them. A merged dataset or a mixture has no run of its
own.
Since qualia-cli 0.3.0rc12; 0.3.0rc11 answers "No such command".

```bash
qualia dataset preparation <DATASET_UUID>
qualia dataset preparation <DATASET_UUID> --wait
qualia dataset preparation my-upload-slug --json
```

Preparation links each found task's episodes to a Task: one that already
existed in the account, when it matches, else a draft it writes. Under the
task's name the table says which where known, then prints the Task's whole id:

```text
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━┳━━━━━━━━┳━━━━━━┓
┃ Task, what it is, its id             ┃ Episodes ┃ Health ┃ Refs ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━━━━╇━━━━━━┩
│ sorting 2 types of screws            │ 6        │ ok     │ 3    │
│ existing Task, linked v1            │          │        │      │
│ 3f2a9c1e-5b7d-4e0a-9c11-2a6d8e4f0b31 │          │        │      │
│                                      │          │        │      │
│ Set the table (2)                    │ 6        │ ok     │ 3    │
│ new draft, not published             │          │        │      │
│ 8d41c7b2-90ae-4f36-b5d2-71c3e9a06f58 │          │        │      │
└──────────────────────────────────────┴──────────┴────────┴──────┘
```

| It says                    | The Task                                                                                                                             |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `existing Task, linked v1` | A publication predates queueing, or the server explicitly matched a published revision. Checks use the stored link revision, here v1 |
| `new draft, not published` | Is a draft preparation wrote for this upload: read it, edit it and publish it                                                        |
| `published v1, 9 Nov`      | The publication is known but its origin is uncertain (`UNKNOWN` in JSON)                                                             |
| `Task not found: …`        | Cannot be read any more: deleted, or out of the key's reach                                                                          |

Publication at or after queueing cannot establish origin. Preparation has no
stable completion timestamp: its last-update time can advance after success
when calibration finishes. Missing timestamps can also leave origin unknown.

When every episode link uses the current published revision, the state also
shows its publication date if known, for example `linked v3, published 4 Nov 2025`.
Older or mixed link revisions never take the current publication's date.

For each existing Task, a line under the table says how many of the dataset's
episodes are linked to it now, so judged against its text, and prints the
command that links them to a Task of your own instead:

```text
6 episode(s) are linked to the existing Task “sorting 2 types of screws” (linked v1: 6 episode(s)): checks use the linked revision's text.
To use a Task of your own instead, keeping their task label:
  qualia dataset assign-task -d accounts/<A>/datasets/<D> --task YOUR_TASK --label 'sorting 2 types of screws' --filter 'linked_task = "accounts/<A>/tasks/3f2a9c1e-5b7d-4e0a-9c11-2a6d8e4f0b31" AND task = "sorting 2 types of screws"'
```

`--label` is the task label those episodes carry; without it
[`assign-task`](#qualia-dataset-assign-task) gives them your Task's name as
their label. Each command filters by its label, even when only one nonempty
label exists. Empty labels get a filtered `dataset episodes` command (follow
`--cursor` for more pages) and `dataset link-episode EPISODE --dataset DATASET
--task YOUR_TASK` for each listed episode; that preserves the empty label.

The report counts the revisions stored in the current links. If a newer one
is published it says `vN is published`. It adds `qualia dataset relink-task DATASET`
only when the dataset links that same Task: the command acts on the dataset's
Task, which may differ from its episodes' Tasks.
Publishing alone does not move the links. When the episodes were linked to
another Task since, the line says that none is linked
to it now. The one Task of a run that discovery did not split is told apart on
its `Task:` line the same way.

The preparation itself does not say which Task is which, so the command reads
each named Task's revisions once, and for an existing Task the episodes linked
to it page by page. Before offering a relink hint, it reads the dataset's Task
link. `--json` has the same facts in `linkedTasks`, beside the fields
the API sends: `task`, `displayName`, `taskKey`, `episodeCount`, `origin`
(`EXISTING`, `NEW_DRAFT`, `MISSING` or `UNKNOWN`), `revision`, `publishTime` and, for an existing Task,
`linkedEpisodeCount`, `labels`, `otherLabelCount`, `linkedRevisions` (counts per
stored revision) and `useOwnTaskCommands`. A draft that task discovery kept
from an earlier run of the same upload also
reads `new draft`. The SDK retains `DRAFT_PUBLISHED` and
`PUBLISHED_DURING_PREPARATION` as accepted values for compatibility, but no
longer infers them from the mutable last-update time.
Since qualia-cli 0.3.0rc14; 0.3.0rc13 prints the id
alone, under `Task id (draft or match)`.

A found task with no Task prints the `qualia task create` line that makes one
from its name and picked reference episodes, and the `link-task` (one task) or
`link-episode` line that links its episodes. A Task id it prints is read with
`qualia task get` and published with `qualia task publish`.

When task discovery could not sort the takes (fewer than 3, too many it could
not read, or an error), a line says why and what the tasks are instead: the
recordings' own task names, or one Task for every episode. Check them before
you publish.

Preparation starts by itself after an upload and takes minutes. Automatic
checks depend on policy and upload eligibility. Free quality checks default
on; paid video (VLM) checks default off and require opt-in. An eligible paid
check may run after preparation if the current policy still enables it and
admission succeeds.

Read before preparation has ended, the command prints a last line that names
`--wait`. `--wait` covers preparation only: it reads it until it has ended,
then prints it. Each change is a line on stderr, so `--json` still prints
only the record on stdout:

```text
Waiting for preparation to end, at most 3600 s (--timeout changes that).
[0:00] Preparation QUEUED: waiting for a worker.
[0:05] Preparation RUNNING: finding the tasks.
[4:10] Preparation RUNNING: 4 task(s) found; linking their episodes to Tasks.
[4:35] Preparation SUCCEEDED.
```

| The preparation                                   | `--wait`                                                                                     | Exit code |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------- | --------- |
| Has ended `SUCCEEDED`                             | Prints it                                                                                    | 0         |
| Has ended, but task discovery failed or fell back | Prints it, with the warning and what the tasks are instead                                   | 0         |
| Has ended `FAILED`                                | Prints it, with its error                                                                    | 1         |
| Was `SKIPPED` (nothing to prepare)                | Prints it                                                                                    | 0         |
| Has no run                                        | Looks again for 30 seconds (an upload's run is queued as it lands), then prints that         | 0         |
| Is still `QUEUED` or `RUNNING` after `--timeout`  | Says on stderr what the last reading showed; the run goes on, so wait again or read it later | 1         |

Task discovery can fail while the run is still `RUNNING`. `--wait` says so at
once, with the reason, and keeps waiting: the run goes on without it and ends
with the recordings' own task names as tasks, or one Task for every episode.
After [`confirm-task-names`](#qualia-dataset-confirm-task-names), `--wait` also
waits for task discovery to finish running again.

Since qualia-cli 0.3.0rc14; 0.3.0rc13 answers "No such option" to `--wait`.

| Option         | Description                           |
| -------------- | ------------------------------------- |
| `--wait`, `-w` | Wait until the preparation has ended  |
| `--timeout`    | Seconds `--wait` waits (default 3600) |
| `--json`       | JSON output                           |

---

### `qualia dataset confirm-task-names`

Decide which of the dataset's own task names become tasks, as the Tasks tab's
**Use these tasks**. Every usable name gets a decision: the ones you pass with
`--use` (or every one with `--all`) are used, the rest are recorded as a
person's no. Read-only names (outcome words, a code, already a task label) are
never sent. The confirmed names start task discovery again, which drafts a Task
for each; read it with `qualia dataset preparation`. Since qualia-cli 0.3.0rc12.

```bash
qualia dataset confirm-task-names <DATASET_UUID> --use ts_3f9a0c1b2d4e
qualia dataset confirm-task-names <DATASET_UUID> --all --name "ts_3f9a0c1b2d4e=Fold the towel"
```

| Option        | Description                                                                                          |
| ------------- | ---------------------------------------------------------------------------------------------------- |
| `--use`, `-u` | Use this task name as a task, by its key (repeatable)                                                |
| `--all`       | Use every task name that can be used                                                                 |
| `--name`      | `KEY=NAME`: the Task name, at most 32 characters. Default: the earlier one, else the first six words |
| `--run`       | The run you read; refused when a newer run replaced it                                               |
| `--json`      | JSON output                                                                                          |

A personal API key decides as the person who made it. A workspace's service key
is refused.

---

### `qualia dataset history`

List an episode's edits, newest first, then the recording as revision 0. Each
row names who edited, when, the note, and the fields the edit changed against the
revision before it. Rulings (keep, reject) are not edits, and an episode nobody
edited lists nothing. The history is the take's: the same through every dataset
that holds it.

```bash
qualia dataset history <EPISODE_UUID> -d <DATASET_UUID>
qualia dataset history <EPISODE_UUID> -d <DATASET_UUID> -n 10 --cursor <CURSOR> --json
```

| Option            | Description                                              |
| ----------------- | -------------------------------------------------------- |
| `--dataset`, `-d` | The dataset the episode is read through (with a bare id) |
| `--limit`, `-n`   | Revisions per page, 1–500 (default 50)                   |
| `--cursor`        | Next-page cursor from the previous page                  |
| `--json`          | JSON output: `episodeRevisions` and `nextPageToken`      |

Each revision in `--json` holds the take's whole content after that edit.

---

### `qualia dataset videos`

Sign each camera's video of an episode, or download them. Each link is
short-lived and stops working at its expiry; run the command again for fresh
ones. When a camera file holds more than the take, the take's window in it is
shown.

```bash
qualia dataset videos <EPISODE_UUID> -d <DATASET_UUID>
qualia dataset videos <EPISODE_UUID> -d <DATASET_UUID> --version stable --download ./videos
```

| Option            | Description                                                                 |
| ----------------- | --------------------------------------------------------------------------- |
| `--dataset`, `-d` | The dataset the episode is read through (with a bare id)                    |
| `--version`       | The take as this version holds it: a tag, `stable`, `v0` or a digest prefix |
| `--download`      | Save each camera's video into this directory                                |
| `--json`          | JSON output; with `--download`, a `downloads` list of saved files           |

`--download DIR` creates `DIR` if needed and saves the whole file of each camera
as `DIR/<camera>.mp4`, replacing a file of that name. A file is written under a
temporary name and renamed once complete, so it is whole or absent. The links go
to storage directly; your API key is never sent there. A take in no published
version yet has no videos, and `--download` then exits 1.

---

### `qualia dataset episodes`

List a dataset's episodes, in recording order unless `--order-by`. The filter
is the one the dataset page's filter bar writes. Each line is one episode: its
id (what the commands below take), index, standing, length after its trim, open
issues and task label. Pass `-` as the dataset to read every dataset of the
account; each line then starts with the episode's full name.

```bash
qualia dataset episodes <DATASET_UUID>
qualia dataset episodes <DATASET_UUID> --filter 'state = UNREVIEWED AND open_issue_codes:"jerky"'
qualia dataset episodes <DATASET_UUID> --order-by 'metrics.duration_seconds desc' -n 20 --json
```

| Option          | Description                                                                                          |
| --------------- | ---------------------------------------------------------------------------------------------------- |
| `--filter`      | Episode filter, e.g. `state = KEPT`, `metrics.duration_seconds < 20`, `note_count > 0`               |
| `--order-by`    | `index` (default), `record_time`, `metrics.<field> desc`, `open_issue_count desc`, `note_count desc` |
| `--view`        | `basic`, or `analysis` to add each episode's analysis                                                |
| `--version`     | Read the episodes as this version froze them: a tag, `stable` or a digest prefix                     |
| `--limit`, `-n` | Page size, 1 to 1000 (default 50)                                                                    |
| `--cursor`      | Cursor from the previous page                                                                        |
| `--json`        | JSON output: `episodes`, `nextPageToken`, `totalSize`                                                |

`open_issue_count` and `open_issue_codes` cover blocking issues.
`note_count` counts notes: findings that do not block an episode (every
recording and graph check finding, and VLM verdicts below their confidence
bar). A note has no code to filter by.

`--version` needs a qualia-sdk whose episode reads take `version=`; with an
older one the command says so and exits 1.

---

### `qualia dataset episode`

Show one episode as its dataset sees it: standing and who ruled, task, labels,
length, trim, subtasks, open issues, and who is editing it in the studio. Pass
`--dataset`/`-d` with a bare episode id, or the full name
`accounts/…/datasets/…/episodes/…`.

```bash
qualia dataset episode <EPISODE_UUID> -d <DATASET_UUID>
qualia dataset episode accounts/<ACCOUNT>/datasets/<DATASET_UUID>/episodes/<EPISODE_UUID> --json
```

| Option            | Description                                       |
| ----------------- | ------------------------------------------------- |
| `--dataset`, `-d` | The dataset it is read through (with a bare id)   |
| `--view`          | `basic`, or `analysis` to add its analysis curves |
| `--version`       | Read it as this version froze it                  |
| `--json`          | JSON output: the episode, including its `etag`    |

---

### `qualia dataset summary`

Count what a filter leaves: episodes, recorded time and frames, how many
episodes carry each value of a field, and each metric's quantiles, histogram and
outlier fences. This is what the dataset page's overview and outliers tab show.

```bash
qualia dataset summary <DATASET_UUID> --facet state --facet open_issue_codes
qualia dataset summary <DATASET_UUID> --filter 'state != REJECTED' --metric metrics.duration_seconds --bins 10
```

| Option     | Description                                                                                                   |
| ---------- | ------------------------------------------------------------------------------------------------------------- |
| `--filter` | Episode filter                                                                                                |
| `--facet`  | Count per value of this field, e.g. `state`, `task`, `outcome`, `open_issue_codes` (repeats; default `state`) |
| `--metric` | Describe this metric's spread, e.g. `metrics.duration_seconds` (repeats)                                      |
| `--bins`   | Histogram bins per metric (default 30); printed only when given                                               |
| `--json`   | JSON output, every count included even when 0; an unfenced metric's fences are `"NaN"`                        |

---

### `qualia dataset keep`

Keep episodes in the dataset's scope, closing their open issues there. Name the
episodes, or pass `--filter` to keep everything it leaves, as the dataset page's
**Keep** does. Through a mixture the keep is the mixture's own, and episodes its
uploads do not keep are skipped.

```bash
qualia dataset keep <DATASET_UUID> <EPISODE_UUID> <EPISODE_UUID> --reason "checked by hand"
qualia dataset keep <DATASET_UUID> --filter 'state = UNREVIEWED AND open_issue_codes:"jerky"'
```

| Option     | Description                                          |
| ---------- | ---------------------------------------------------- |
| `--filter` | Keep every episode this filter leaves, not names     |
| `--reason` | Words for the record                                 |
| `--json`   | JSON output: every count of the result, and `locked` |

The command prints how many episodes changed, were already kept, and were
skipped, and how many open issues it closed. Name episodes or pass a filter, not
both. An empty `--filter ""` is refused, so a blank shell variable never selects
every episode.

A take a person has open in the studio is left as it was: their edit lease
wins over a key. Those takes are listed on stderr with who holds them, and the
command still exits 0. Run it again once they are done. `--json` puts the same
list under `locked` (the first 100) beside `lockedCount`.

---

### `qualia dataset reject`

Reject episodes in the dataset's scope: they leave training there. Nothing is
deleted, and `qualia dataset reset` withdraws it. Same selection, output and
locked takes as `keep`.

```bash
qualia dataset reject <DATASET_UUID> <EPISODE_UUID> --reason "operator in frame"
qualia dataset reject <DATASET_UUID> --filter 'metrics.duration_seconds < 2'
```

| Option     | Description                                        |
| ---------- | -------------------------------------------------- |
| `--filter` | Reject every episode this filter leaves, not names |
| `--reason` | Words for the record                               |
| `--json`   | JSON output                                        |

---

### `qualia dataset reset`

Withdraw the keep or reject made in the dataset's scope, as Ctrl+Z does in the
studio. Each episode then stands as derived: unreviewed while a blocking issue
is open on it, kept otherwise. Issues the withdrawn keep closed open again. A
reset carries no reason.

```bash
qualia dataset reset <DATASET_UUID> <EPISODE_UUID>
qualia dataset reset <DATASET_UUID> --filter 'metrics.duration_seconds < 2'
```

| Option     | Description                                       |
| ---------- | ------------------------------------------------- |
| `--filter` | Reset every episode this filter leaves, not names |
| `--json`   | JSON output                                       |

---

### `qualia dataset restore`

Put back episodes ingest validation rejected, with the reason the check was
wrong. Made in the upload only. Episodes that are not validation rejects are
skipped.

```bash
qualia dataset restore <DATASET_UUID> <EPISODE_UUID> --reason "the fps metadata was right"
```

| Option     | Description                        |
| ---------- | ---------------------------------- |
| `--reason` | Why the check was wrong (required) |
| `--json`   | JSON output                        |

### `qualia dataset issues list`

List a dataset's issues and notes, oldest first. An issue blocks a keep until
it is dismissed or its episode is ruled; a note never blocks. Through a mixture
the list includes the issues upstream of it, under their own names; filter on
`dataset = "<the mixture's name>"` for its own.

```bash
qualia dataset issues list <DATASET_UUID> --filter 'state = OPEN AND blocks = true'
qualia dataset issues list <DATASET_UUID> --episode <EPISODE_UUID> --json
```

| Argument / option | Description                                                    |
| ----------------- | -------------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)                       |
| `--filter`        | AIP-160 over `episode`, `code`, `kind`, `state`, `blocks`, ... |
| `--episode`, `-e` | Only this episode's issues (id or full name)                   |
| `--order-by`      | `create_time` (default), `create_time desc`, `code`, `episode` |
| `--limit`, `-n`   | Rows per page, 1-1000 (default 50)                             |
| `--cursor`        | Pagination cursor, as the previous page printed                |
| `--json`          | JSON output                                                    |

### `qualia dataset issues summary`

Count issues per code: open, the distinct episodes with an open one, and
dismissed. The dataset page's issues table. A person's own words show as
`(own words)`.

```bash
qualia dataset issues summary <DATASET_UUID>
qualia dataset issues summary <DATASET_UUID> --filter 'blocks = true' --json
```

| Argument / option | Description                              |
| ----------------- | ---------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required) |
| `--filter`        | Same language as `list`                  |
| `--json`          | JSON output                              |

### `qualia dataset issues dismiss`

Settle open issues by name or by filter: seen, and the episode wanted as it
stands. No ruling moves. Notes and issues already dismissed are left alone.
Issues on takes another person is editing stay open; the command prints how
many it dismissed, then those takes and who holds them.

```bash
qualia dataset issues dismiss <DATASET_UUID> --filter 'code = "idle_tail"' --reason "fine for this task"
qualia dataset issues dismiss <DATASET_UUID> <ISSUE_UUID> <ISSUE_UUID>
```

| Argument / option | Description                                           |
| ----------------- | ----------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug: the scope (required)   |
| `ISSUES`          | Issue ids or full names; omit to select with --filter |
| `--filter`        | Issues matching this filter                           |
| `--reason`, `-r`  | Words for the record                                  |
| `--cause`         | `MANUAL` (default), or `TRIM` when a trim cut it away |
| `--json`          | JSON output                                           |

Name issues or pass `--filter`, not both. Through a mixture only its own issues
are dismissed.

### `qualia dataset issues reopen`

Reopen dismissed issues by name or by filter. A person's ruling on an episode
whose blocking issue reopens is withdrawn: the episode is unreviewed again in
this dataset. Takes another person is editing are left as they were and listed.

```bash
qualia dataset issues reopen <DATASET_UUID> --filter 'code = "idle_tail" AND state = DISMISSED'
```

| Argument / option | Description                                           |
| ----------------- | ----------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug: the scope (required)   |
| `ISSUES`          | Issue ids or full names; omit to select with --filter |
| `--filter`        | Issues matching this filter                           |
| `--json`          | JSON output                                           |

### `qualia dataset issues add`

Raise an issue, or with `--note` write a note, in your own words, on an episode
or the whole dataset. An issue on an episode withdraws a person's ruling on it
in this dataset; deleting the issue puts the ruling back.

```bash
qualia dataset issues add <DATASET_UUID> "Gripper slips at the second grasp" -e <EPISODE_UUID> --at 12.5
qualia dataset issues add <DATASET_UUID> "Recorded on the new rig" --note
```

| Argument / option | Description                                              |
| ----------------- | -------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug: the scope (required)      |
| `TEXT`            | One sentence; its first line is the title (required)     |
| `--episode`, `-e` | The episode it is about; omit for the whole dataset      |
| `--note`          | Write a note, which never blocks a keep                  |
| `--at`            | A moment in the episode, seconds from its start          |
| `--body`          | More than the sentence says                              |
| `--request-id`    | Idempotency key: a retry with it returns the first issue |
| `--json`          | JSON output                                              |

### `qualia dataset issues delete`

Withdraw an issue or note you raised. An API key withdraws only what it raised
itself. An issue others replied to needs `--force`, which deletes their replies
too.

```bash
qualia dataset issues delete <ISSUE_UUID> -d <DATASET_UUID> --yes
```

| Argument / option | Description                                         |
| ----------------- | --------------------------------------------------- |
| `ISSUE`           | Issue id or full name (required)                    |
| `--dataset`, `-d` | The dataset it is read through; needed with an id   |
| `--force`         | Also delete replies others wrote under it           |
| `--yes`, `-y`     | Skip the confirmation (required without a terminal) |
| `--json`          | JSON output                                         |

### `qualia dataset issues replies`

List the replies under an issue or note, oldest first.

```bash
qualia dataset issues replies <ISSUE_UUID> -d <DATASET_UUID>
```

| Argument / option | Description                                       |
| ----------------- | ------------------------------------------------- |
| `ISSUE`           | Issue id or full name (required)                  |
| `--dataset`, `-d` | The dataset it is read through; needed with an id |
| `--limit`, `-n`   | Rows per page, 1-500 (default 50)                 |
| `--cursor`        | Pagination cursor, as the previous page printed   |
| `--json`          | JSON output                                       |

### `qualia dataset issues reply`

Reply under an issue or note.

```bash
qualia dataset issues reply <ISSUE_UUID> "Trimmed it away" -d <DATASET_UUID>
```

| Argument / option | Description                                              |
| ----------------- | -------------------------------------------------------- |
| `ISSUE`           | Issue id or full name (required)                         |
| `TEXT`            | What to say (required)                                   |
| `--dataset`, `-d` | The dataset it is read through; needed with an id        |
| `--request-id`    | Idempotency key: a retry with it returns the first reply |
| `--json`          | JSON output                                              |

### `qualia dataset get`

Show one dataset: its kind, episode counts, robots, task link, tags, reach
settings and etag. `DATASET` is an id, a full name or a slug
(the dashboard address, `/datasets/<slug>`).

```bash
qualia dataset get kitchen-run
qualia dataset get <DATASET_UUID> --json
```

`--json` prints the qualia.v1 `Dataset`.

---

### `qualia dataset update`

Change a dataset's name, description, target robot or reach settings. Only what you pass changes. A merged dataset writes its target robot
and reach settings to every upload it holds.

```bash
qualia dataset update kitchen-run --name "Kitchen, day 2" --description ""
qualia dataset update kitchen-run --target-robot <ROBOT_UUID>
qualia dataset update kitchen-run --reach-base 0,0.1,0 --reach-heading 0.5
qualia dataset update kitchen-run --reach-reset
```

| Option            | Description                                                                 |
| ----------------- | --------------------------------------------------------------------------- |
| `--name`          | New display name                                                            |
| `--description`   | New description; `''` clears it                                             |
| `--target-robot`  | The robot it plays on: a robot id or `accounts/.../robots/...`; `''` clears |
| `--reach-base`    | `x,y,z` metres from the grippers' midpoint in the robot's frame, or `auto`  |
| `--reach-heading` | Rotate both hand paths around world up, radians (within ±π)                 |
| `--reach-reset`   | Clear both reach settings                                                   |
| `--etag`          | Refuse if the dataset changed since this etag                               |

The reach flags change one value at a time: the command reads the stored
settings, changes what you passed and writes them back under the etag it read.
Saving them starts nothing; the next reach check uses them. A dataset has no
solver of its own: it follows its robot's, so change the solver on the robot.
Saving the reach settings also drops a solver that was stored on the dataset
earlier.

---

### `qualia dataset merge`

Merge uploads into one dataset, or add uploads to a merged one. A merge is
final: it cannot be undone. Preview it with `--dry-run`, which writes nothing.

A new merge takes at least two uploads. For one dataset that grows with every
batch, upload the first two batches each on its own
(`qualia dataset upload DIR NAME --standalone`) and merge them once with
`--name`. From then on each batch appends as it is uploaded, with
`qualia dataset upload DIR NAME --merge-target <MERGED_DATASET>`, or without
the flag once [`qualia dataset destination set`](#qualia-dataset-destination)
has saved it. Asked to merge one upload, the command says so and names these
steps; `--into` adds an upload that already exists to a merged dataset.

```bash
qualia dataset merge day-1 day-2 --name "Bench" --dry-run
qualia dataset merge day-1 day-2 --name "Bench" --resolve target_robot=accounts/<a>/robots/<r>
qualia dataset merge day-3 --into bench --yes
```

| Option          | Description                                                                |
| --------------- | -------------------------------------------------------------------------- |
| `--name`        | Name of the new merged dataset (or pass `--into`)                          |
| `--into`        | Add the uploads to this merged dataset                                     |
| `--description` | Description of the new merged dataset                                      |
| `--resolve`     | Answer a conflict: `FIELD=VALUE`, or `channel_key:CHANNEL=KEY`; repeatable |
| `--dry-run`     | List what differs between the uploads                                      |
| `--yes`, `-y`   | Skip the confirmation (required without a terminal)                        |

A blocking conflict (features, cameras, channel names, fps, where the poses
are) settles only by leaving uploads out. The others take one value each:
answer them with `--resolve`; labels and the task default to the uploads'
commonest.

---

### `qualia dataset remap`

Map native channel names onto canonical keys where the automatic grammar got
them wrong, then derive the robot interface again. Renames add to earlier ones.

```bash
qualia dataset remap kitchen-run --rename /arm/joint_0=left.joint_0.pos --rename /arm/joint_1=
```

`NATIVE=` resets that channel to the key read from its name. A key another
channel holds is refused and nothing is written: rename both to swap them.

---

### `qualia dataset reingest`

Ingest an MCAP upload's recordings again under a new `meta/ingest.toml`, or read
a re-ingest that this command or `resolve-poses` started.

```bash
qualia dataset reingest kitchen-run --config ./ingest.toml --wait
qualia dataset reingest kitchen-run --status <REINGEST_ID> --wait
```

| Option      | Description                                                    |
| ----------- | -------------------------------------------------------------- |
| `--config`  | The complete `meta/ingest.toml`; it replaces the published one |
| `--status`  | Read this re-ingest (its id or full name) instead              |
| `--wait`    | Wait until it succeeds or fails                                |
| `--timeout` | Seconds `--wait` waits (default 3600)                          |

The upload's next generation is published; the old one stays readable and
versions cut earlier keep what they pinned. The published config again runs
nothing. A config with `[episodes] spans` re-cuts the upload and is refused
while any episode holds a person's ruling, issue, note or edit. Mixtures,
merged datasets and LeRobot uploads are refused.

---

### `qualia dataset resolve-poses`

Resolve a handheld upload's poses at each hand's tool centre point, with a
gripper's tracker-to-TCP calibration, or undo that with `--root`.

```bash
qualia dataset resolve-poses kitchen-run --gripper <GRIPPER_UUID> --wait
qualia dataset resolve-poses kitchen-run --root
```

The calibration is read and sent as it stands now; a gripper without one is
refused before anything is sent. The work re-derives the whole upload, which
takes as long as checking its video again. Without `--wait`, follow it with
`qualia dataset reingest <DATASET> --status <ID> --wait`.

The same action as [`qualia dataset poses --at`](#qualia-dataset-poses), which also
finds the upload's capture gripper.

---

### `qualia dataset dismiss-upload`

Stop the upload feed reporting a failed or finished upload. The record of why
it failed and the uploaded bytes are kept. A live upload is never hidden.

```bash
qualia dataset dismiss-upload <REPO_ID>
qualia dataset dismiss-upload <REPO_ID> --through <UPDATED_AT_UNIX>
```

`REPO_ID` is `repo_id` in `qualia dataset uploads --json`. `--through` is the
`updated_at_unix` you saw, so a failure recorded after it stays. Organization
keys only.

---

### `qualia dataset cancel-upload`

Cancel an upload in progress, from any machine. The uploading process is
refused its next step and stops; server-side work stops on its next heartbeat.
Nothing is published. Refused once the upload has published. Organization keys
only.

```bash
qualia dataset cancel-upload <REPO_ID>
```

---

### `qualia dataset imports`

List the account's server-side Hugging Face imports, show one, or cancel one.
Organization keys only.

```bash
qualia dataset imports
qualia dataset imports <IMPORT_ID> --json
qualia dataset imports <IMPORT_ID> --cancel
```

---

### `qualia dataset copy`

Copy a dataset into another workspace, of this organization or another one.
The copy is a new dataset whose history starts at the copied version, and it
records where it came from. It shares the source's content, so inside one
organization no media bytes are written. It prints what it did:

```bash
qualia dataset copy fold-bench --to Bench
# Copied 4.0 GiB, 34 episodes in 0.8 s
# Fold bench: accounts/<account>/datasets/<copy-id>
qualia dataset copy <DATASET_UUID> --to "Partner Lab/Bench" --version v3 --name "Fold bench"
# Copied 4.0 GiB (12 MiB written), 34 episodes in 1.4 s
```

The dataset is found in the CLI's workspace (`--workspace` or
`QUALIA_WORKSPACE`, else the key's own), by id, full name, slug or display
name. `--to <workspace>` is a workspace of the key's organization, by name or
id. `--to <org>/<workspace>` names an organization and a workspace, each by name
or id. An organization named by name resolves through the places this key can
transfer to from the CLI's workspace, so set `--workspace` or
`QUALIA_WORKSPACE` for it; without one, name it by ids. A target the rule
refuses prints the reason before anything is called.

Inside one organization you need read on the source and Contributor or higher
in the target.
Into another organization you need Admin or Owner in the source organization,
and Contributor or higher in the target. A personal API key acts as its
creator. A workspace or service key reaches only its own organization, and a
key limited to some workspaces can target only those. A refusal prints the
server's reason and exits 1.

A tagged version that left episodes out is copied with new episode numbers, so
the files that mix kept and left-out episodes are rewritten: tables always, and
video too when the copy leaves the organization. A video
that can't be cut cleanly is re-encoded, and the result says its pixels changed.

```bash
qualia dataset copy fold-bench --to "Partner Lab/Bench" --version v3
# Request id 3f1c…: if this stops, run it again within 24 hours with --request-id 3f1c…
# Copying 26 episodes: 4 of 9 files.
# Copied 1.6 GiB (1.5 GiB rewritten, 3.0 GiB written), 26 episodes in 3 min
```

Such a copy can run past its call. The command waits and prints how far it got
on stderr. Stopping the command stops nothing. The request id prints before the
first request: run the same command with `--request-id` to pick the copy up
again, within 24 hours of the first call. `--request-id` never starts a copy: if
the server no longer holds that id, the command says the copy stopped and
exits with status 1. Run it without `--request-id` to start again.

| Option         | Description                                                                 |
| -------------- | --------------------------------------------------------------------------- |
| `--to`         | Target workspace: `<workspace>` or `<org>/<workspace>` (required)           |
| `--version`    | Version tag to copy; default the current version                            |
| `--name`       | Name of the copy; default the source's, or `<name> (copy)` if taken         |
| `--no-wait`    | Return at once with how far a running copy got                              |
| `--request-id` | Pick up a copy by the id it printed, with the same options, within 24 hours |
| `--json`       | JSON output: the dataset, counts, bytes and seconds                         |

---

### `qualia dataset move`

Move a dataset to another workspace of this organization. It keeps its id,
name, history, versions, checks and flags. You need Admin or Owner in the
source workspace and Contributor or higher in the target. A move is refused
across organizations (copy instead), while a mixture in the source workspace
reads the dataset, and when its name is taken in the target.

```bash
qualia dataset move fold-bench --to Bench
# Moved 4.0 GiB, 34 episodes in 0.1 s
```

| Option   | Description                                         |
| -------- | --------------------------------------------------- |
| `--to`   | Target workspace of this organization: name or id   |
| `--json` | JSON output: the dataset, counts, bytes and seconds |

### `qualia dataset setup`

Set a dataset up for the reach solver: the robot it plays on and
the placement. A dataset follows its robot's solver, which is set on the robot. It does what the dashboard's Setup strip does, so a handheld
(UMI) dataset can be set up from a terminal or a pipeline. Only what you pass is
written; the rest of the reach settings are kept, and repeating a command
writes nothing.

```bash
qualia dataset setup <DATASET_UUID> --robot <ROBOT_UUID>
qualia dataset setup <DATASET_UUID> --base 0 0 0 --heading 0
qualia dataset setup <DATASET_UUID> --robot <ROBOT_UUID> --wait
qualia dataset setup <DATASET_UUID> --run-solver
```

For a handheld upload, picking a robot with `--robot` also **starts the resolve
at the TCP**, when all of this holds:

- the dataset is an upload with handheld poses (not a mixture or a merged
  dataset);
- every arm of the robot has a TCP (a robot with one bare arm would leave one
  hand at the gripper root and the other at the TCP);
- the workspace has exactly one UMI rig of the upload's kind (the capture
  gripper) and it has a tracker-to-TCP calibration;
- the poses are not at the TCP yet, or are there with another calibration.

The resolve uses the capture gripper's calibration, not the robot's own
gripper's: the robot only decides when. It re-derives the whole upload, so it
takes as long as ingesting it did. Without `--wait` the command prints the
operation and how to follow it (`qualia dataset poses --follow`). When nothing
starts, the command says why and leaves the poses where they are. With several UMI
rigs of one kind none is the upload's: resolve it with
`qualia dataset poses --at tcp --gripper <RIG>`.

The solver is never started by itself. `--run-solver` presses Run solver once,
after waiting for a resolve this command started; follow the run with
[`qualia check reach-status`](#qualia-check-reach-status).

| Option                       | Description                                                                                           |
| ---------------------------- | ----------------------------------------------------------------------------------------------------- |
| `DATASET`                    | Dataset id, full name or slug (required)                                                              |
| `--robot`, `-r`              | The robot it plays on: id or `accounts/.../robots/...`; `''` clears it                                |
| `--base X Y Z`               | Where the takes' start sits from the grippers' midpoint: metres in the robot's base frame, within 5 m |
| `--heading`                  | Rotate the paired paths around up, degrees (+/- 180)                                                  |
| `--resolve` / `--no-resolve` | Resolve the poses at the TCP whatever the robot / never (default: by the rules above)                 |
| `--wait`, `-w`               | Wait for a resolve to finish (minutes to hours)                                                       |
| `--run-solver`               | Press Run solver afterwards                                                                           |
| `--json`                     | JSON output                                                                                           |

Progress of a wait goes to stderr, so `--json` stays one document on stdout.

---

### `qualia dataset poses`

Show where a handheld upload's poses are, or move them. Without `--at` it
prints `root` (the gripper root's poses), `tcp` (each hand's TCP too) or
`recorded` (a raw recording, which a resolve converts), the rig, the capture
gripper, the calibration the poses were resolved with, and whether that gripper's
calibration has changed since.

```bash
qualia dataset poses <DATASET_UUID>
qualia dataset poses <DATASET_UUID> --at tcp --wait
qualia dataset poses <DATASET_UUID> --follow <OPERATION_ID>
qualia dataset poses <DATASET_UUID> --at root
```

`--at tcp` re-derives the upload with the capture gripper's calibration (the
same command again after the gripper's calibration changed; with the same one it
changes nothing), `--at root` goes back. A ruling or trim made while it runs
fails it: run it again.

| Option         | Description                                                                                                                |
| -------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `DATASET`      | Dataset id, full name or slug (required)                                                                                   |
| `--at`         | `tcp` or `root`: move the poses (without it: show where they are)                                                          |
| `--wait`, `-w` | With `--at`: wait for the re-derive to finish                                                                              |
| `--follow`     | Wait for a resolve you started without `--wait`: its id or operation name                                                  |
| `--gripper`    | With `--at tcp`: resolve with this gripper's calibration instead of the capture gripper's (required with several UMI rigs) |
| `--json`       | JSON output                                                                                                                |

---

## `qualia mixture`

A mixture is a dataset kept up to date from other datasets. It reads sources: a
dataset by name, every upload with one label value, or every upload linked to a
Task. A dataset source follows `stable` (its newest tag, the default), `head`
(what it yields now), or is pinned to one tag. Tag a mixture with
[`qualia dataset version create`](#qualia-dataset-version-create); delete one
with `qualia dataset delete`.

### `qualia mixture create`

Make a mixture with its sources, or from a version.

```bash
qualia mixture create "Folding, all rigs" \
  --source dataset=rig-a-folding \
  --source dataset=rig-b-folding@1.2 \
  --source label=task:fold@head \
  --source task=<TASK_UUID>

# a mixture that reads the dataset as version 1.0 froze it, to curate further
qualia mixture create --from rig-a-folding:1.0
```

| Option           | Description                                                                                                                                                             |
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `NAME`           | Display name; with `--from` optional                                                                                                                                    |
| `--source`, `-s` | Repeatable: `dataset=<id, name or slug>[@stable\|@head\|@<tag>]`, `label=<task\|robot\|operator\|method\|scene>:<value>[@stable\|@head]`, `task=<task>[@stable\|@head]` |
| `--from`         | `<dataset>:<tag>`: the dashboard's **Curate from this version**. Not with `--source`                                                                                    |
| `--description`  | Free text                                                                                                                                                               |
| `--json`         | `{"mixture": {...}, "sources": [...]}`                                                                                                                                  |

The sources are added together, or none of them. When they are refused, the
mixture made stays and the error names it.

---

### `qualia mixture sources`

List a mixture's sources in panel order: what each reads, how it follows it,
its episodes and a newer tag when a pinned source has one.

```bash
qualia mixture sources folding-all-rigs --json
```

---

### `qualia mixture remove-source`

Remove a source from its mixture. Asks first; `--yes` skips the question.

```bash
qualia mixture remove-source accounts/…/datasets/…/sources/… --yes
qualia mixture remove-source <SOURCE_ID> --mixture folding-all-rigs --yes
```

---

### `qualia mixture pin-source`

Keep a dataset source on one tag of what it reads. A pinned source holds its
dataset out of no merge, so an upload a mixture reads can then be merged.

```bash
qualia mixture pin-source <SOURCE_ID> --mixture folding-all-rigs
qualia mixture pin-source <SOURCE_ID> --mixture folding-all-rigs --tag 1.0
```

Without `--tag` it pins the dataset's newest tag. It refuses when the source
follows `head` and the dataset changed since that tag, since the pin would drop
those changes; tag them first, or pass `--tag` to pin anyway.

---

### `qualia mixture readers`

The mixtures that read a dataset directly, each with how it follows it.

```bash
qualia mixture readers rig-a-folding --json
```

---

## `qualia robot`

Register robots, import their models and give them a gripper. A robot **owns its
gripper**: attaching one copies a gripper's URDF, TCP and jaw into a gripper of
the robot's own, so lining it up or editing its TCP changes that robot only, and
the copy is a snapshot (recalibrating the handheld later does not change it). A
robot that carries a gripper never swaps it: to try another gripper, copy the
robot. Every command takes `--json`, and a robot is an id, `accounts/.../robots/...`
or its display name, looked up among robots only (`show` also finds gripper
designs).

### `qualia robot list`

```bash
qualia robot list
qualia robot list --gripper
```

Robots oldest first (the first is the default target of a dataset). `--gripper`
lists the workspace's gripper designs, such as the handheld's, and the copies robots
own (each named like its source; the Owner column names the robot) instead. `--limit`
(`-n`) and `--cursor` page.

### `qualia robot show`

```bash
qualia robot show <ROBOT_UUID>
```

The robot's type, serial number, whether it has a model, the gripper it carries
and where it sits on each arm (`guessed` until you set the mounts). For a gripper:
its TCP, the calibration file and date, and the rig it was copied from.

### `qualia robot get`

The same as `qualia robot show`, for a caller that wants a robot: a gripper is
refused, a display name is looked up among robots only, and the etag is printed.

```bash
qualia robot get <ROBOT> --json
```

---

### `qualia robot create`

```bash
qualia robot create "Lab arm" --type franka_fr3 --serial SN-1
qualia robot create "Lab arm" --urdf lab_arm.zip
```

`--urdf` also imports a file in one step: a `.urdf`, a `.zip` of
the URDF and its meshes, or a directory. When the file never reaches an import,
the robot made for it is deleted again; an import that starts and then fails keeps
the robot, and the error names it. With `--request-id` the robot is kept, and running
the command again follows the import instead of uploading again.

| Option                | Description                                                         |
| --------------------- | ------------------------------------------------------------------- |
| `NAME`                | Display name, unique in the account (required)                      |
| `--type`, `-t`        | Robot type key (`franka_fr3`, `so101_follower`); none for URDF-only |
| `--serial`            | Serial number, free text                                            |
| `--request-id`        | Idempotency key (a UUID4) for a retry                               |
| `--urdf`              | Import this file in one step                                        |
| `--wait`, `--no-wait` | With `--urdf`: wait for the conversion (default on)                 |
| `--timeout`           | With `--urdf`: seconds to wait (default 1800)                       |

### `qualia robot import`

```bash
qualia robot import <ROBOT_UUID> lab_arm.urdf
qualia robot import <ROBOT_UUID> ./rizon_description     # a directory is zipped
qualia robot import <ROBOT_UUID> rizon.zip --no-wait
```

Replaces the robot's model with a `.urdf`, a `.zip` of the URDF and its meshes,
or a directory (hidden VCS folders are left out). The file is uploaded (at most
256 MiB) and the server converts the meshes: a second for a URDF, minutes for a
large mesh package. The command waits and prints each state on stderr;
`--no-wait` returns once the import is accepted, and `--timeout` bounds the wait
(seconds, default 1800). A URDF the server cannot use ends `failed` and the
command exits 1 with the server's reason. A file symlinked within a directory is
zipped with its content; a link to a file outside it, a link to a directory or a
broken link is refused by name.

### `qualia robot model`

```bash
qualia robot model <ROBOT_UUID>
qualia robot model <ROBOT_UUID> --solver dls --reach-base 0 0 0
qualia robot model <ROBOT_UUID> --arms-file arms.json
```

Without an option it prints the model: the import state, solver, each arm's hand
link and TCP, the jaw and the reach base. Options change only what they name.

| Option               | Description                                                                                                                   |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `--solver`           | `pyroki` or `dls`                                                                                                             |
| `--arms-file`        | A JSON list of arms `{"side": "left", "hand_link": "...", "tool": {"xyz": [], "rpy": []}, "start_posture": []}`; replaces all |
| `--reach-base X Y Z` | Where a dataset's reach check starts when it names no base: metres in the robot's base frame, within 5 m                      |
| `--clear-reach-base` | Remove it                                                                                                                     |
| `--etag`             | Refuse if the model changed since this etag                                                                                   |

### `qualia robot copy`

```bash
qualia robot copy <ROBOT_UUID>
qualia robot copy <ROBOT_UUID> --name "Lab arm, parallel gripper" --gripper <GRIPPER_UUID>
```

Makes a new robot from an existing one, to try another gripper or a mount without
touching the original. The copy takes the robot's type, URDF and meshes, arms,
solver, jaw, reach base and capsules and a gripper of its own, and shares nothing
with it afterwards; its serial number, datasets, reach results and history are not
copied. `--gripper` is `keep` (the default: a copy of the gripper it carries, with
its mounts), `none`, or a gripper or robot to copy a gripper from instead (mounted
at each arm's hand link: line it up with `qualia robot gripper mount`). `keep` and
`none` are matched in any case; any other value must be a robot id (a UUID) or
resource name, and is checked before the copy is made. A copy is refused while the
model is being imported or the capsules fitted: try again in a minute.

With another gripper this is two calls, the copy and then the attach. If the attach
fails, the gripper-less copy stays: the command exits 1 with the attach's own error
and a note naming the copy, which you can give a gripper with
`qualia robot gripper attach <COPY> --copy-from ...` or delete. Run again with the
same `--request-id` to retry; a copy that already carries a gripper is returned as
it is.

### `qualia robot rename` and `qualia robot delete`

```bash
qualia robot rename <ROBOT_UUID> "Lab arm 2"
qualia robot delete <ROBOT_UUID> --force --yes
```

Deleting a robot deletes the gripper it owns, files included. It is refused while
the robot has a model unless `--force`; datasets that played on it keep a dangling
reference. It asks first, or pass `--yes`. With `--json` it prints
`{"robot": ..., "deleted": true, "force": ...}`.

---

### `qualia robot update`

Edit a robot's name, type or serial number. `''` clears a type or serial. For
the name, `qualia robot rename` is the same action.

```bash
qualia robot update <ROBOT> --name "Lab arm 2" --serial SN-0042
```

| Option     | Description                       |
| ---------- | --------------------------------- |
| `--name`   | New name                          |
| `--type`   | Robot type key, e.g. `franka_fr3` |
| `--serial` | Serial number                     |
| `--etag`   | Refuse if changed since this etag |
| `--json`   | JSON output                       |

---

### `qualia robot configure`

Correct what the import guessed. Only what you pass changes. The command reads
the model and saves under its etag, so a change made meanwhile is refused.
`qualia robot model` edits the arms and solver too, and `qualia robot gripper jaw`
the jaw.

The jaw lives in the model of the gripper the robot carries, so `--jaw-*` and
`--no-jaw` write there: a second write when you also change the arms or solver.
`--etag` covers the robot's model only. With `--json` a command that wrote both
adds the gripper's model as `carriedGripperModel`.

Moving an arm's hand link starts its posture at rest unless `--home` sets it,
and moves the carried gripper's mount on that arm along, as the Rigs page
does.

```bash
qualia robot configure <ROBOT> --arm left=left_link7 --arm right=right_link7
qualia robot configure <ROBOT> --home right=0.2,0,0,0,0,0
qualia robot configure <ROBOT> --solver dls --jaw-joint '{side}_finger_joint1' --jaw-open 0.8 --jaw-closed 0
```

| Option         | Description                                                         |
| -------------- | ------------------------------------------------------------------- |
| `--arm`        | An arm's hand link: `left=LINK` or `right=LINK` (repeatable)        |
| `--home`       | An arm's home posture, radians, one per turning joint: `left=a,b,…` |
| `--solver`     | IK solver: `pyroki` or `dls`                                        |
| `--jaw-joint`  | The joint that opens the jaw; `{side}` is left or right             |
| `--jaw-open`   | Its position fully open                                             |
| `--jaw-closed` | Its position shut                                                   |
| `--jaw-home`   | Its position at home                                                |
| `--no-jaw`     | Clear the jaw joint                                                 |
| `--etag`       | Refuse if the robot's model changed since this etag                 |
| `--json`       | JSON output                                                         |

---

### `qualia robot carry`

Give a robot a gripper, set the mounts of the one it carries, or take it off, in
one command. It does what `qualia robot gripper attach --copy-from`, `mount` and
`detach` do.

On a robot that carries none, `GRIPPER` is copied and the copy is the robot's own.
With no `--mount` or `--baked`, it creates a mount at each
arm's hand link with no offset, for each hand the gripper has a TCP in a link for.
A gripper with no such TCP gets no mount; set one with `qualia robot gripper tcp`
or `calibration`, then `qualia robot gripper mount`.

Naming the gripper the robot carries sets its mounts; without `--mount` each arm
keeps a mount that still hangs on its hand link. Any other gripper is refused: a
robot never swaps its gripper, so `qualia robot copy` it with the other one.
`--none` deletes the robot's own gripper: it asks first, or pass `--yes`.

```bash
qualia robot carry <ROBOT> <GRIPPER>
qualia robot carry <ROBOT> <ITS_GRIPPER> --mount left=left_link7:left-mount.json --baked right
qualia robot carry <ROBOT> --none --yes
```

| Option        | Description                                                                                                                                  |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `--none`      | Take the gripper off, which deletes it                                                                                                       |
| `--mount`     | `left=LINK` or `left=LINK:matrix.json`: the gripper's hand link in LINK's frame, 16 numbers row-major, metres (default identity). Repeatable |
| `--baked`     | `left` or `right`: the robot's URDF has the gripper built in on that arm                                                                     |
| `--etag`      | Refuse if changed since this etag                                                                                                            |
| `--yes`, `-y` | With `--none`: skip the confirmation                                                                                                         |
| `--json`      | JSON output                                                                                                                                  |

---

### `qualia robot datasets`

List the datasets played on a robot (their target robot). Takes `--limit`,
`--cursor` and `--json`.

```bash
qualia robot datasets <ROBOT>
```

---

### Two names for one action

Some actions have two commands. Both work; the last column says what differs.

| Command                                                      | Also                             | What differs                                                                      |
| ------------------------------------------------------------ | -------------------------------- | --------------------------------------------------------------------------------- |
| `qualia robot show`                                          | `qualia robot get`               | `get` refuses a gripper and prints the etag                                       |
| `qualia robot rename`                                        | `qualia robot update --name`     | `update` also takes `--type`, `--serial` and `--etag`                             |
| `qualia robot model` with options                            | `qualia robot configure`         | `configure` sets one arm at a time, the jaw, and moves mounts with an arm         |
| `qualia robot gripper jaw`                                   | `qualia robot configure --jaw-*` | `configure` changes one jaw field at a time                                       |
| `qualia robot gripper attach --copy-from`, `mount`, `detach` | `qualia robot carry`             | One command; see above                                                            |
| `qualia robot gripper tcp --file`                            | `qualia gripper tcp`             | `gripper tcp` takes a design or a robot, by name among designs                    |
| `qualia robot gripper calibration`                           | `qualia gripper calibrate`       | The same                                                                          |
| `qualia robot list --gripper`                                | `qualia gripper list`            | The same rows, with the TCP state and rig key                                     |
| `qualia robot show <GRIPPER_ID>`                             | `qualia gripper get`             | `get` refuses a robot and looks a name up among designs                           |
| `qualia robot import <GRIPPER_ID> FILE`                      | `qualia gripper import`          | `gripper import` refuses a robot and looks a name up among designs                |
| `qualia robot delete <GRIPPER_ID> --force`                   | `qualia gripper delete`          | `gripper delete` refuses a robot, always forces and looks a name up among designs |
| `qualia dataset poses --at`                                  | `qualia dataset resolve-poses`   | `poses` also finds the capture gripper                                            |

---

### `qualia robot gripper`

The robot's own gripper. Each command takes the robot that carries it.

```bash
qualia robot gripper attach <ROBOT_UUID> --copy-from <GRIPPER_UUID>
qualia robot gripper attach <ROBOT_UUID> --urdf ./parallel_gripper
qualia robot gripper mount <ROBOT_UUID> -m left:left_hand:0,0,0.02 -m right:right_hand:0,0,0.02
qualia robot gripper jaw <ROBOT_UUID> --joint '{side}_finger_joint' --open 0.04 --shut 0
qualia robot gripper tcp <ROBOT_UUID> --hand 'left:gripper_{side}_ee:0,0,0.12' --hand 'right:gripper_{side}_ee:0,0,0.12'
qualia robot gripper calibration <ROBOT_UUID> tcp_calibration.json
qualia robot gripper detach <ROBOT_UUID>
```

**`attach`** gives a robot that carries none a gripper. `--copy-from` copies a
gripper design (the handheld's, a library gripper) or another robot's own gripper:
its URDF and meshes, TCP and jaw as they are now. `--urdf` uploads a gripper of the
robot's own (a `.urdf`, `.zip` or directory) and imports it; it has no TCP yet, so
set one before you mount it. A robot that carries a gripper is refused:
`qualia robot copy` it with the other gripper, or `detach` first.

**`mount`** sets where the gripper sits on each arm and replaces all mounts, one per
arm at most. A mount is `SIDE:LINK[:X,Y,Z[:ROLL,PITCH,YAW]]`, the position in metres
and the rotation in radians (URDF convention) of the gripper's hand link in `LINK`'s
frame, where `LINK` is a link of the robot joined to the arm's hand link by fixed
joints only; `SIDE:baked` says the robot's own URDF already has the gripper built in.
The gripper's hand needs a TCP in a link first. `attach --copy-from` takes `--mount`
too; without it the copy is mounted at each arm's hand link with no offset, `guessed`.

**`jaw`** names the joint that opens the gripper (`{side}` stands for left or right)
and its position open and shut, checked against the URDF's limits, and optionally
`--home`, its position at the arm's home (never inferred; left out, the home the
model already has for the same joint is kept). It edits the model the jaw lives in:
the carried gripper's, or the robot's own when its URDF has the gripper built in or
it carries none.

**`tcp`** sets the TCP of each hand, `SIDE:LINK:X,Y,Z[:ROLL,PITCH,YAW]` in a link of
the gripper's URDF (`{side}` in a link stands for the side); a hand keeps its link
once set, and hands not named keep theirs. Or `--file` a JSON of
`{"left": {"link": "...", "matrix": [... 16 numbers ...]}, "right": {...}}`.
**`calibration`** replaces the TCPs with a `tcp_calibration.json`. **`detach`** takes
the gripper off, which deletes it: its URDF, meshes, TCP and jaw.

On a workspace that has not been migrated yet, a robot may still carry a gripper
shared with other robots. `jaw`, `tcp` and `calibration` refuse to edit it by way of
the robot (the error names the gripper), because the edit would change every robot
that carries it; pass the gripper's id instead of the robot's to edit it for all of
them. `mount` is not affected: mounts are the robot's own.

| Option                                  | Description                                            |
| --------------------------------------- | ------------------------------------------------------ |
| `--copy-from` / `--urdf`                | `attach`: where the gripper comes from (exactly one)   |
| `--name`                                | `attach --urdf`: the gripper's name                    |
| `--mount`, `-m`                         | `attach --copy-from`, `mount`: a mount, repeat per arm |
| `--joint`, `--open`, `--shut`, `--home` | `jaw`: the joint and its positions                     |
| `--hand`, `--file`                      | `tcp`: the hands' TCPs                                 |
| `--filename`                            | `tcp`, `calibration`: the name recorded with the TCP   |
| `--etag`                                | Refuse if it changed since this etag                   |
| `--yes`, `-y`                           | `detach`: skip the confirmation                        |

---

## `qualia gripper`

Gripper designs: one gripper's URDF and the tool centre point (TCP) of each hand
on it. A robot takes a copy of its own (`qualia robot gripper attach --copy-from`,
`qualia robot carry`), so changing a design reaches no robot that already has a
copy. A UMI rig is a gripper made from a gripper design
(`qualia gripper umi-rig`, or **New rig, UMI** in the dashboard) and calibrated (`qualia gripper calibrate`): handheld
uploads are converted at it and resolved at its TCP. A workspace has the UMI rigs someone made and none otherwise; nothing makes one for you. A display name is looked up among designs only, never
among the copies robots own (they are named like their source): name a copy by its
id, or use `qualia robot gripper`.

### `qualia gripper list`

List the workspace's gripper designs, oldest first, with whether each has a TCP
and which are UMI rigs (UMI rig, Rig key). The copies robots own are listed too, named like their source; Owner is the robot
that carries one. Same list as `qualia robot list --gripper`.
Takes `--limit`, `--cursor` and `--json`.

---

### `qualia gripper get`

Show a gripper: whether it is a UMI rig, its rig key, whether uploads resolve with it, the calibration
file and date, and each hand's link and tracker-to-TCP.

```bash
qualia gripper get <GRIPPER>
```

---

### `qualia gripper create`

Register a gripper design from its URDF or `.zip`, as "+ gripper" does. The name
defaults to the file name; a taken name is refused. When the file never reaches
an import, the gripper made for it is deleted again. Takes `--name`,
`--wait/--no-wait`, `--timeout` (default 600) and `--json`.

```bash
qualia gripper create parallel_gripper.urdf
```

---

### `qualia gripper import`

Import a new URDF onto a gripper. A TCP set in one of its links goes stale
until you set it again. Takes `--wait/--no-wait`, `--timeout` and `--json`.

---

### `qualia gripper delete`

Delete a gripper design and its model (account owners only). Robots that copied
it keep their copy. A robot's own gripper, named by its id, can be deleted too:
it comes off its robot, as `qualia robot gripper detach` does, unless a live dataset
still plays back with it. A UMI rig is an ordinary gripper: a delete removes it for
good. Pass `--yes` when there is no terminal.

---

### `qualia gripper umi-rig`

Make a UMI rig from a gripper design (a template), as **New rig, UMI** does in the
dashboard: a copy
of its URDF and TCP, made once and never re-synced. The rig is the capture rig a
handheld recording is converted with, and an upload names it with `--rig`. A
workspace can have several; each is named by its name or id. With a
tracker-to-TCP calibration on the design the rig resolves uploads at once;
otherwise import its `tcp_calibration.json` with
[`qualia gripper calibrate`](#qualia-gripper-calibrate). Needs `rigs:write` on
the organization.

```bash
qualia gripper umi-rig "KIWI pair" --name "Kitchen UMI"
qualia gripper calibrate "Kitchen UMI" tcp_calibration.json
```

| Option         | Description                                              |
| -------------- | -------------------------------------------------------- |
| `--name`       | The rig's name; default `<design> UMI`                   |
| `--request-id` | Idempotency key (a UUID4): a retry returns the first rig |
| `--json`       | JSON output                                              |

---

### `qualia gripper tcp`

Set where the TCP is on each hand: a 4x4 matrix in one of the gripper's URDF
links. Hands the file leaves out keep theirs. At most 64 KiB. Same action as
`qualia robot gripper tcp --file`, which takes the robot.

```bash
qualia gripper tcp <GRIPPER> tcp.json
```

```json
{
  "left": {
    "link": "{side}_ee_base_link",
    "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0.1, 0, 0, 0, 1]
  },
  "right": {
    "link": "{side}_ee_base_link",
    "matrix": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0.1, 0, 0, 0, 1]
  }
}
```

`matrix` is 16 numbers, row-major, metres, or a nested 4x4. `{side}` in a link
is replaced by `left` or `right`.

| Option       | Description                                                  |
| ------------ | ------------------------------------------------------------ |
| `--filename` | What to call the calibration; default `tcp-matrix:<gripper>` |
| `--etag`     | Refuse if the TCP changed since this etag                    |
| `--json`     | JSON output                                                  |

---

### `qualia gripper calibrate`

Import a handheld rig's `tcp_calibration.json`: each hand's tracker-to-TCP, and
the TCP in the gripper's end-effector link where the file gives it. It replaces
the gripper's TCP. Takes `--filename`, `--etag` and `--json`. Same action as
`qualia robot gripper calibration`, which takes the robot.

```bash
qualia gripper calibrate <GRIPPER> tcp_calibration.json
```

---

## `qualia task`

Manage tasks: what a robot should do and how success shows. A task is a draft of
five fields (description, scene, steps, success, failure) and an optional
procedure, published as numbered revisions that never change. The
[Robots and Tasks](https://junction-docs.qualiastudios.dev/global/guides/robots-and-tasks.md) guide walks through it.

Every `qualia task` command acts in the API key's own account; a key acts in no
other, so `--account` and `QUALIA_ACCOUNT` are not needed. See
[Authentication](https://junction-docs.qualiastudios.dev/cli/overview.md#authentication). A task is named by its id or
its full name, `accounts/<ACCOUNT_UUID>/tasks/<TASK_ID>`.

### `qualia task list`

List the account's tasks, newest first, with their latest revision and whether
the draft has unpublished changes.

A key restricted to workspaces reads no tasks.

```bash
qualia task list
qualia task list -n 100
```

| Option          | Description                    |
| --------------- | ------------------------------ |
| `--limit`, `-n` | Page size, 1–500 (default: 20) |
| `--cursor`      | Pagination cursor              |
| `--json`        | JSON output                    |

---

### `qualia task get`

Show a task with its draft, or one of its published revisions. `--json` prints
the task as the API returns it; edit that file and pass it to `qualia task edit`.

```bash
qualia task get <TASK_ID>
qualia task get <TASK_ID> -r 2
qualia task get <TASK_ID> --json > task.json
```

| Option             | Description                          |
| ------------------ | ------------------------------------ |
| `--revision`, `-r` | Show this published revision instead |
| `--json`           | JSON output                          |

---

### `qualia task create`

Create a task. Its draft comes from a file or from `--description`. Nothing is
published. A task needs a name: `--name`, or `displayName` in the file.

```bash
qualia task create --name "Cube in box" -d "Pick the cube off the table and put it in the box"
qualia task create task.yaml --name "Cube in box"
```

The file is YAML or JSON. It holds either a task as `qualia task get --json`
prints it (`displayName`, `draft`, `references`), or only the draft fields:

```yaml
description: Pick the cube off the table and put it in the box
scene: A cube and an open box on a table; the cube's start position varies
steps: |
  Grasp the cube
  Move it over the box
  Release it
success: The cube rests inside the box
failure: The cube falls outside the box
```

| Option                | Description                                                |
| --------------------- | ---------------------------------------------------------- |
| `--name`              | Display name (overrides the file)                          |
| `--description`, `-d` | What the robot should do                                   |
| `--success`           | A picked success reference episode (repeatable, 1–3)       |
| `--failure`           | A picked failure reference, `MODE=<episode>` (repeatable)  |
| `--recovery`          | A picked recovery reference, `MODE=<episode>` (repeatable) |
| `--json`              | JSON output                                                |

Reference episodes are full names, `accounts/.../datasets/.../episodes/...`.
Pick them in the file or with options, not both.

---

### `qualia task edit`

Save a task's draft from a file, or single fields from options. A file replaces
the whole draft, procedure included. An option replaces only that field, and
`""` clears it. Published revisions never change.

```bash
qualia task edit <TASK_ID> --success "The cube rests inside the box"
qualia task edit <TASK_ID> --steps $'Grasp the cube\nRelease it over the box'
qualia task edit <TASK_ID> task.json
```

| Option          | Description                                |
| --------------- | ------------------------------------------ |
| `--name`        | New display name                           |
| `--description` | What the robot should do                   |
| `--scene`       | The scene the task runs in                 |
| `--steps`       | The steps, one per line                    |
| `--success`     | How success shows                          |
| `--failure`     | How it fails                               |
| `--etag`        | Refuse if the task changed since this etag |
| `--json`        | JSON output                                |

Pass a draft file or field options, not both. A file from
`qualia task get --json` carries the task's etag, so the save is refused if
someone changed the task since.

---

### `qualia task publish`

Publish the draft as the next revision. It needs a description and a success;
the other fields are optional. A procedure, when there is one, must have one
start, a success outcome, every step reachable and no loops. Publishing also
links the unlinked episodes and uploads whose task label is exactly the task's
name.

```bash
qualia task publish <TASK_ID> -m "First revision"
```

| Option         | Description                                 |
| -------------- | ------------------------------------------- |
| `--note`, `-m` | What changed                                |
| `--etag`       | Publish only the draft as read at this etag |
| `--json`       | JSON output                                 |

---

### `qualia task revisions`

List a task's published revisions, newest first: number, note, content hash, who
published it and when.

```bash
qualia task revisions <TASK_ID>
```

| Option          | Description                    |
| --------------- | ------------------------------ |
| `--limit`, `-n` | Page size, 1–500 (default: 20) |
| `--cursor`      | Pagination cursor              |
| `--json`        | JSON output                    |

---

### `qualia task link-episodes`

Link the unlinked episodes and uploads whose task label names the task. It lists
the labels that match exactly beside the near misses (case, spacing,
punctuation) in your workspace. Without `--label` it links the exact matches;
with it, the labels you pass. A link already set is never changed, and labels
stay as they are.

```bash
qualia task link-episodes <TASK_ID> --dry-run
qualia task link-episodes <TASK_ID> -l "cube in box" -l "Cube in box."
```

| Option          | Description                                     |
| --------------- | ----------------------------------------------- |
| `--label`, `-l` | A label to link, exactly as listed (repeatable) |
| `--dry-run`     | List the matching labels and link nothing       |
| `--json`        | JSON output                                     |

---

### `qualia task delete`

Delete a task. Only account owners can. A task with a published revision is
refused unless you pass `--force`, which deletes its revisions too. It asks for
confirmation; pass `--yes` when there is no terminal.

```bash
qualia task delete <TASK_ID>
qualia task delete <TASK_ID> --force --yes
```

| Option        | Description                        |
| ------------- | ---------------------------------- |
| `--force`     | Delete its published revisions too |
| `--etag`      | Refuse if changed since this etag  |
| `--yes`, `-y` | Skip the confirmation              |

---

### `qualia task references`

Show the episodes a person picked for a task as references: each one's id,
role (success, failure, recovery), mode and episode.

```bash
qualia task references <TASK_ID>
```

| Option          | Description       |
| --------------- | ----------------- |
| `--limit`, `-n` | Page size, 1–500  |
| `--cursor`      | Pagination cursor |
| `--json`        | JSON output       |

---

### `qualia task reference add`

Pick an episode as one of the task's references, as yourself. A task holds 1 to
3 success references and any number of failure and recovery modes, each with 1
or 2. A failure or recovery names its mode; a success has none. You need read
access to the episode's dataset.

```bash
qualia task reference add <TASK_ID> accounts/<ACCOUNT_UUID>/datasets/<DATASET_ID>/episodes/<EPISODE_ID>
qualia task reference add <TASK_ID> <EPISODE_NAME> --role failure --mode slip --start 1.5 --end 4
```

| Option    | Description                                          |
| --------- | ---------------------------------------------------- |
| `--role`  | `success` (default), `failure` or `recovery`         |
| `--mode`  | The failure or recovery it shows (1–80 characters)   |
| `--start` | Range start, seconds into the episode (with `--end`) |
| `--end`   | Range end, seconds (with `--start`)                  |
| `--json`  | JSON output                                          |

The episode is its full name, as `qualia task references --json` or
`Episode.upload` names it.

---

### `qualia task reference rm`

Remove a reference from the task. The episode is untouched. It asks for
confirmation; pass `--yes` when there is no terminal.

```bash
qualia task reference rm <TASK_ID> <REFERENCE_ID> --yes
```

| Option        | Description                   |
| ------------- | ----------------------------- |
| `--yes`, `-y` | Skip the confirmation         |
| `--json`      | JSON output (`{}` on success) |

---

### `qualia task draft`

Ask a VLM to draft the task's fields from exactly its picked references. It
never publishes. `--estimate` prices it and starts nothing.

```bash
qualia task draft <TASK_ID> --estimate
qualia task draft <TASK_ID> --wait
```

| Option       | Description                                     |
| ------------ | ----------------------------------------------- |
| `--model`    | `qualia-vlm-fast`, the default and the only one |
| `--max-cost` | Credit cap                                      |
| `--estimate` | Price the whole selection; start nothing        |
| `--wait`     | Wait for the stored proposal                    |
| `--json`     | JSON output                                     |

A draft is made as a person. A personal API key acts as the person who created
it, so `--estimate` and the draft work with it. A service key (owned by a
workspace or the organization) is refused
(`a person or their personal API key must draft a Task`).

`qualia task draft-get <DRAFT>` reads a stored proposal and changes nothing.
`qualia task draft-apply <DRAFT>` saves it as the task's draft; pass
`--file <spec.yaml>` to save your reviewed version instead. Publish it with
`qualia task publish` after review.

---

### `qualia task drafts`

List a task's VLM drafts: each run's id, state, model, credits, and error.

```bash
qualia task drafts <TASK_ID>
```

| Option          | Description                    |
| --------------- | ------------------------------ |
| `--limit`, `-n` | Page size, 1–500 (default: 20) |
| `--cursor`      | Pagination cursor              |
| `--json`        | JSON output                    |

---

### `qualia task linked`

List the datasets linked to a task, or with `--episodes` its episodes from every
dataset. An episode with no link of its own reads its dataset's link; the
`Link` column says which. The global `--workspace` picks the workspace.

```bash
qualia task linked <TASK_ID>
qualia task linked <TASK_ID> --episodes -n 100
```

| Option          | Description                                  |
| --------------- | -------------------------------------------- |
| `--episodes`    | List the linked episodes instead of datasets |
| `--limit`, `-n` | Page size, 1–500 (default: 20)               |
| `--cursor`      | Pagination cursor                            |
| `--json`        | JSON output                                  |

---

### `qualia task link`

Link one episode to a task. A link, not an edit: the episode's label, revisions
and versions stay as they are. `--unlink` removes the episode's own link only if
it is to this task, and the episode then reads its dataset's link again. To link
a whole upload, use [`qualia dataset link-task`](#qualia-dataset-link-task).

```bash
qualia task link <TASK_ID> --episode <EPISODE_NAME>
qualia task link <TASK_ID> --episode <EPISODE_ID> --dataset <DATASET_ID>
qualia task link <TASK_ID> --episode <EPISODE_NAME> --unlink
```

| Option            | Description                                                          |
| ----------------- | -------------------------------------------------------------------- |
| `--episode`, `-e` | Episode id (with `--dataset`) or full name                           |
| `--dataset`, `-d` | The dataset an episode id is read through: its id, full name or slug |
| `--unlink`        | Remove the episode's own link to this task                           |
| `--json`          | JSON output                                                          |

---

## `qualia git`

### `qualia git install`

Enable fast large-file transfer for a cloned Qualia dataset repo. Run it once
inside the clone; it configures the Git filter and adds the tracked patterns to
`.gitattributes`, after which large files sync chunk-by-chunk instead of moving
whole.

```bash
git clone https://junction-api.qualiastudios.dev/git/<org>/<name>.git
cd <name>
qualia git install
```

| Option   | Description                                   |
| -------- | --------------------------------------------- |
| `--path` | Path to the cloned dataset repo (default `.`) |

Tracked by default: `*.safetensors`, `*.gguf`, `*.parquet`, `*.mp4`, `*.mcap`,
`*.bag`, `*.npz`, `*.npy`, `*.pt`, `*.bin`, `*.tar`, `*.zip`. Patterns already
present in `.gitattributes` are left alone.

The clone's `origin` must be the same endpoint the CLI is configured against —
the filter refuses a repo whose remote points somewhere other than the
configured base URL, so a repo cloned from one environment cannot be pushed to
another by changing the URL.

Without an API key the filter cannot move data; `qualia git install` says so
rather than failing later inside a `git` command.

---

---

## `qualia status`

Running work and what ended recently, as the dashboard's Running page shows
it: ingests, check runs, training jobs and pipeline runs. A status is named
under its workspace (`accounts/<A>/workspaces/<W>/statuses/<OPERATION>`), or
under the organization for the organization's own work.

### `qualia status list`

List running work and work that ended within `--recent`, most recently
changed first. The default is the workspace the CLI acts in (`--workspace`,
else your key's own).

```bash
qualia status list
qualia status list --running
qualia status list --org --recent 7d --json
qualia status list <WORKSPACE_ID> --filter 'service = "pipeline" AND state != FAILED'
```

| Argument / Option | Description                                                                        |
| ----------------- | ---------------------------------------------------------------------------------- |
| `PARENT`          | Workspace ID or resource name (default: the workspace the CLI acts in)             |
| `--org`           | The whole organization: every workspace's work and its own                         |
| `--running`       | Only work that is queued or running                                                |
| `--recent`        | How far back ended work goes: `90s`, `30m`, `24h`, `7d` (default 24h, max 90 days) |
| `--filter`        | AIP-160 filter on `state`, `kind` and `service`; `OR` binds tighter than `AND`     |
| `--limit`, `-n`   | Page size, 1–500 (default 50)                                                      |
| `--cursor`        | Pagination cursor printed by the previous page                                     |

Each line is the operation ID, kind, state, progress, target and last change,
and `cancellable` when `qualia status cancel` can stop it. A key restricted to
workspaces cannot read or cancel statuses.

### `qualia status cancel`

Ask the service running the work to stop it. Needs `jobs:write` and a
contributor role on the work's workspace. The status turns `CANCELLED` a
moment after the service accepts; ended work, or a kind that cannot be
cancelled, is refused. A running VLM or heuristic check run is cancelled with
its pipeline run, which returns the credits held for the episodes it had not
read; the run then reads failed, "cancelled by" you. A VLM run that stops
answering fails on its own after 30 minutes with no episode finished.

A check run's id is its operation's ID, so a check run can also be named as
[`qualia check runs`](#qualia-check-runs) prints it (`<check>/<run id>`) or by
its full name.

```bash
qualia status cancel <OPERATION_ID>
qualia status cancel vlm/<RUN_ID>
qualia status cancel accounts/<A>/workspaces/<W>/statuses/<OPERATION_ID> --json
```

---

## `qualia check`

Run a check over a dataset and read what it found. The default check is the
VLM check (`vlm`): a model reads each episode against the Task it is linked
to. Confident failures become issues; uncertain readings become notes. An issue
holds the episode out of kept until a person keeps or rejects it. A check never
keeps, rejects or deletes an episode.

The VLM check costs credits. Run `qualia check estimate` first: it shows the
price and starts nothing. A run never spends more than it reserved.

`qualia check` also reads the thresholds the episode rules judge against
(`ranges`) and the reach check's status and results (`reach-status`,
`reach-cancel`, `results`).

Every `qualia check` command acts in the API key's own account, so `--account`
and `QUALIA_ACCOUNT` are not needed. See
[Authentication](https://junction-docs.qualiastudios.dev/cli/overview.md#authentication).

`qualia check` runs one check by its slug. To run several checks from the
catalog in one go, use [`qualia dataset checks`](#qualia-dataset-checks).

### `qualia check policy` and `policy-set`

Control automatic checks on new uploads without disabling a person's manual
`check run`. Free quality checks (heuristic) default **on**; paid video checks
(graph) default **off**. Each half inherits independently: dataset override →
workspace default → organization default → platform default. Reading prints
`on` or `off` and its effective source (`dataset`, `workspace`, `org` or
`platform`); `--json` also includes the override at the named level.

```bash
qualia check policy <DATASET_UUID>                        # effective dataset policy
qualia check policy <WORKSPACE_UUID> --workspace            # workspace default
qualia check policy <ORG_UUID> --org                         # organization default
qualia check policy-set <ORG_UUID> --org --graph on           # opt in to paid video checks
qualia check policy-set <WORKSPACE_UUID> --workspace --heuristic off
qualia check policy-set <DATASET_UUID> --graph inherit       # remove dataset override
qualia check policy-set <DATASET_UUID> --heuristic on --graph off --json
```

`--org` and `--workspace` _after_ `policy`/`policy-set` choose that policy's
parent, not the CLI's global `--workspace` (placed before `check`). The parent
is a UUID or its full `accounts/{account}/…` name. `policy-set` requires at
least one of `--heuristic` or `--graph`, each `on`, `off` or `inherit`; an
unmentioned half stays unchanged. Setting `inherit` removes that half's
explicit override, never the other half's.

Org and workspace defaults take an Admin or Owner at that level and an API key
with opt-in `members:write`; dataset changes take `data:write` there. A
workspace-bound key cannot set the org default. Switching on starts nothing
and does not backfill: **Applies to new uploads. Run checks to check existing
episodes.** `qualia check estimate` shows the cost of a manual video run before
`qualia check run`. See [automatic checks in the SDK](https://junction-docs.qualiastudios.dev/sdk/resources/checks.md#automatic-checks-on-new-uploads)
for GET/PATCH resource names and inheritance details.

A check run is named with its check: `<check>/<run id>`, for example
`vlm/<RUN_ID>`, which is how [`qualia check runs`](#qualia-check-runs) prints
each run. Its full name, `accounts/<account>/checks/<check>/runs/<run id>`,
which `qualia check run` and `--json` print, works too. Wherever a command
takes a check run (`qualia check status`, `qualia check get`, `--run` of
`qualia dataset checks findings` and `qualia status cancel`), pass either;
neither costs a request. A run id alone is refused with what to pass instead: a
run is read under its check, so its id names nothing without it.

### `qualia check estimate`

What a VLM check would read and reserve. Starts and charges nothing. It prints
the credits, the episode count (and how many are left out because they are not
linked to a published Task), the model it would use and every model offered.
It also fails the way the run would, for example when no episode is linked to
a published Task. Right after an upload that refusal usually means preparation,
which links the episodes, has not ended. While it has not, the error ends with
one more line that says so and names the command that waits for it,
[`qualia dataset preparation <DATASET> --wait`](#qualia-dataset-preparation).

Models are Qualia model tiers: `qualia-vlm` (the strongest), `qualia-vlm-fast`
and `qualia-vlm-lite` (images only, the cheapest). Which tiers a check offers is
what `estimate` lists. The VLM check reads each episode's task graph on
`qualia-vlm` only; naming another tier is refused before anything starts.

```bash
qualia check estimate <DATASET_UUID>
qualia check estimate <DATASET_UUID> --max-episodes 50 --max-cost 200
```

| Argument / option | Description                                                     |
| ----------------- | --------------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)                        |
| `--episode`, `-e` | Check only this episode (repeatable)                            |
| `--filter`        | Check only episodes matching this filter                        |
| `--model`, `-m`   | Qualia model tier; one of the tiers `estimate` lists as offered |
| `--max-episodes`  | Sample at most N episodes                                       |
| `--max-cost`      | Credit ceiling                                                  |
| `--json`          | JSON output                                                     |

Pass the same `--max-episodes` and `--max-cost` to `qualia check run` to
reserve the same amount.

### `qualia check run`

Start a check over a dataset. Without `--check` it runs the VLM check. It
prints the name of each run it started (one per upload of a merged dataset);
`--wait` waits for each run to end and prints its result.

While another VLM run is on the dataset (the automatic one after preparation, or
anyone's), the VLM run is queued behind it and starts when
that ends: `Queued <run>: queued behind the automatic VLM check started 10:41 UTC
(<run>); it starts when that ends`. A queued run reserves credits only when it
starts. Two runs queued on one dataset run in the order they were asked for.
`qualia check estimate` answers either way.
A VLM run asked for before preparation has linked the episodes is refused as
[`estimate`](#qualia-check-estimate) is, with the same line naming the wait.
`--check reach` presses Run solver on a dataset: set the robot first with
[`qualia dataset setup`](#qualia-dataset-setup), and follow the run with
[`qualia check reach-status`](#qualia-check-reach-status).

```bash
qualia check run <DATASET_UUID> --max-episodes 50 --max-cost 200 --wait
qualia check run <DATASET_UUID> --check reach
```

| Argument / option | Description                                                     |
| ----------------- | --------------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)                        |
| `--check`, `-c`   | Check slug (default `vlm`); `-` runs the dataset's defaults     |
| `--episode`, `-e` | Check only this episode (repeatable)                            |
| `--filter`        | Check only episodes matching this filter                        |
| `--model`, `-m`   | Qualia model tier; one of the tiers `estimate` lists as offered |
| `--max-episodes`  | Sample at most N episodes                                       |
| `--max-cost`      | Credit ceiling                                                  |
| `--wait`, `-w`    | Wait for each run to end; exits 1 if one failed                 |
| `--json`          | JSON output                                                     |

### `qualia check status`

Show where a run stands: its state, how many issues it raised and resolved, and
for the VLM check the model, the episodes planned and the pass, fail and
uncertain counts. A run that could not read some episodes still ends
`SUCCEEDED`, with a `Result:` line such as `completed, 4 of 30 episodes not
checked` and each of those episodes (up to 100) with why. Each also gets an open
"Not checked" issue, so it is no longer kept; the next run that reads it closes
that issue.

A graph check run that asks questions about what it removed prints them last,
under `Review health:`, as the dashboard's **Review health** card shows them:
each question, why it asks and a few example episodes, then the spot check.
`--json` has every episode, under `vlm.reviewHealth`.

```bash
qualia check status vlm/<RUN_ID>
qualia check status accounts/<ACCOUNT>/checks/vlm/runs/<RUN_ID>
```

| Argument / option | Description                                               |
| ----------------- | --------------------------------------------------------- |
| `RUN`             | `<check>/<run id>`, as `runs` prints it, or the full name |
| `--json`          | JSON output                                               |

### `qualia check runs`

List check runs, newest first: one line for each run, under a heading. The
first column names the run as `<check>/<run id>`, which is what
[`qualia check status`](#qualia-check-status) takes; then its state, how many
issues it raised and when it was created (UTC). The lines are plain text, not a
boxed table, so the run is never cut to fit a narrow terminal. With no run to
list it prints `No check runs.`

```bash
qualia check runs --dataset <DATASET_UUID>
qualia check runs --check vlm --state RUNNING
```

```text
Run                                            State     Raised Created
vlm/550e8400-e29b-41d4-a716-446655440000       SUCCEEDED     13 2026-11-09 21:58
heuristic/660e8400-e29b-41d4-a716-446655440000 SUCCEEDED      0 2026-11-09 21:58
```

| Option            | Description                                     |
| ----------------- | ----------------------------------------------- |
| `--check`, `-c`   | Check slug; `-` for all (default `-`)           |
| `--dataset`, `-d` | Only this dataset's runs                        |
| `--state`         | `QUEUED`, `RUNNING`, `SUCCEEDED` or `FAILED`    |
| `--limit`, `-n`   | Rows per page, 1-1000 (default 20)              |
| `--cursor`        | Pagination cursor, as the previous page printed |
| `--json`          | JSON output                                     |

### `qualia check findings`

The issues and notes a check raised on a dataset, with the model's reading:
the episode, the verdict, the confidence, the moments in seconds it points at,
and the text. For what catalog checks wrote per episode, passes included, use
[`qualia dataset checks findings`](#qualia-dataset-checks-findings).

```bash
qualia check findings <DATASET_UUID>
qualia check findings <DATASET_UUID> --limit 100 --json
```

| Argument / option | Description                                     |
| ----------------- | ----------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)        |
| `--check`, `-c`   | Check slug (default `vlm`)                      |
| `--filter`        | More issue filter, ANDed                        |
| `--limit`, `-n`   | Rows per page, 1-1000 (default 20)              |
| `--cursor`        | Pagination cursor, as the previous page printed |
| `--json`          | JSON output                                     |

### `qualia check get`

One check: its kind, what it checks, the dataset kinds it runs on, and whether
this deployment can run it. `qualia check get vlm` says whether the VLM check
is set up here.

Handed a check run instead of a check, it shows where that run stands, as
[`qualia check status`](#qualia-check-status) does.

```bash
qualia check get
qualia check get reach --json
qualia check get vlm/<RUN_ID>
```

| Argument / option | Description                                                                  |
| ----------------- | ---------------------------------------------------------------------------- |
| `CHECK`           | `vlm`, `episode-rules`, `mixture` or `reach` (default `vlm`); or a check run |
| `--json`          | JSON output                                                                  |

### `qualia check ranges`

The reference ranges the episode rules judge against, in your workspace. Each
range is one metric on one robot interface (or one robot): its quantiles and
the fences an episode is judged against. An upload's interface is its
`robotInterface.interfaceHash` (SDK `client.datasets.get`).

```bash
qualia check ranges --interface-hash b3:<HEX>
qualia check ranges --metric duration_seconds --json
```

| Option             | Description                                       |
| ------------------ | ------------------------------------------------- |
| `--check`, `-c`    | Check slug (default `episode-rules`)              |
| `--interface-hash` | Only this robot interface (`b3:…`)                |
| `--robot`          | Only ranges narrowed to this robot (id or name)   |
| `--metric`         | Only this metric                                  |
| `--filter`         | More range filter (interface_hash, robot, metric) |
| `--limit`, `-n`    | Rows per page, 1-1000 (default 20)                |
| `--cursor`         | Pagination cursor, as the previous page printed   |
| `--json`           | JSON output                                       |

### `qualia check reach-status`

Where a dataset's reach check (Run solver) stands: the robot, the settings a run
would use now, the last run with how many takes it solved, and whether it is
stale (the robot, model, placement or poses changed since). It reads only;
nothing runs by itself. A dataset with no usable robot has no key, and a run
would start nothing.

```bash
qualia check reach-status <DATASET_UUID>
```

| Argument / option | Description                              |
| ----------------- | ---------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required) |
| `--json`          | JSON output                              |

Press Run solver with `qualia check run <DATASET> --check reach` or
`qualia dataset setup <DATASET> --run-solver`.

### `qualia check reach-cancel`

Stop the Run solver press that is under way. Takes not yet sent to the solver
are dropped and takes already solved keep their results: the next run answers
them from storage and solves the rest. It prints the status after the cancel.

```bash
qualia check reach-cancel <DATASET_UUID>
```

| Argument / option | Description                              |
| ----------------- | ---------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required) |
| `--json`          | JSON output                              |

### `qualia check results`

What the reach check stored per take: for each handheld or egocentric take on
the dataset's target robot, its state (`NOT_RUN` where nothing is solved under
the current key yet), how many stretches the robot cannot follow and the
longest, in seconds.

```bash
qualia check results <DATASET_UUID>
qualia check results <DATASET_UUID> --state SUCCEEDED --json
```

| Argument / option | Description                                             |
| ----------------- | ------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)                |
| `--check`, `-c`   | Check slug (default `reach`; only reach stores results) |
| `--episode`, `-e` | Only this episode                                       |
| `--state`         | `PENDING`, `SUCCEEDED`, `FAILED` or `NOT_RUN`           |
| `--robot`         | Only results on this robot (id or name)                 |
| `--filter`        | More result filter, e.g. `reach.longest_seconds > 1`    |
| `--limit`, `-n`   | Rows per page, 1-1000 (default 20)                      |
| `--cursor`        | Pagination cursor, as the previous page printed         |
| `--json`          | JSON output                                             |

### `qualia dataset checks`

The checks catalog on one dataset. Catalog checks have an id such as
`heuristic.jerk` or `task.graph`, a category, and a kind. Heuristic checks are
free; VLM checks use a model and cost credits. `qualia dataset checks run` runs
one kind per selection; `qualia dataset checks findings` reads what they found.

```bash
qualia dataset checks list <DATASET_UUID>
qualia dataset checks run <DATASET_UUID> --kind heuristic --category heuristic --validate-only
qualia dataset checks run <DATASET_UUID> --kind heuristic --all --wait
qualia check estimate <DATASET_UUID> --max-cost 200
qualia check run <DATASET_UUID> --max-cost 200 --wait
qualia dataset checks findings <DATASET_UUID> --verdict FAIL
```

`list` shows every catalog check by category: its kind, whether it can run on
this dataset (or why not) and its last result. The `Check` column is the id
`--check` takes and is never cut; in a narrow terminal the sentences beside it
wrap instead.

`run` takes the union of `--check`, `--category` and `--all`. Name at least one.
`--kind` pins which checks a category or `--all` selects; unset, the current
catalog selects runnable heuristics (VLM catalog checks are unavailable). It lists skipped
checks with reasons, except the VLM check's internal graph steps. VLM catalog
checks cannot run here: use `qualia check estimate` and `qualia check run` for
the supported VLM check, with `--max-cost` covering the estimate. An explicit
`--kind heuristic` catalog run needs no credit ceiling.

| Option (`run`)    | Description                                             |
| ----------------- | ------------------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)                |
| `--check`, `-c`   | A catalog check id, e.g. `heuristic.jerk` (repeatable)  |
| `--category`      | Every runnable check of this category (repeatable)      |
| `--all`           | Every catalog check of the selected kind that can run   |
| `--kind`          | Pin `heuristic` or `vlm` for `--all` and `--category`   |
| `--episode`, `-e` | Check only this episode (repeatable)                    |
| `--filter`        | Check only episodes matching this filter                |
| `--model`, `-m`   | Qualia model tier for the VLM checks in the selection   |
| `--max-episodes`  | Sample at most N episodes                               |
| `--max-cost`      | Credit ceiling; required for VLM runs unless validating |
| `--validate-only` | Resolve the selection and its bound; start nothing      |
| `--wait`, `-w`    | Wait for the run to end; exits 1 if it failed           |
| `--json`          | JSON output                                             |

### `qualia dataset checks findings`

What catalog checks found on a dataset, newest first: per check, episode and
finding, the verdict (`PASS`, `FAIL` or `UNKNOWN`), what it raised (`ISSUE`,
`NOTE` or `NONE`), the Task revision it read (`Task rev`; `draft` for an
unpublished Task) and a title.

It lists only the findings that stand: per check and episode, those of the
newest run that read the episode at its linked Task revision (its own link,
else its dataset's). Publishing a newer revision leaves them standing; after
`qualia dataset relink-task`, an episode has none until a run reads the new
revision. `--all-revisions` lists every run's findings, superseded ones
included.

```bash
qualia dataset checks findings <DATASET_UUID>
qualia dataset checks findings <DATASET_UUID> --check task.graph --verdict FAIL
qualia dataset checks findings <DATASET_UUID> --run vlm/<RUN_ID> --json
```

| Argument / option | Description                                     |
| ----------------- | ----------------------------------------------- |
| `DATASET`         | Dataset id, full name or slug (required)        |
| `--check`, `-c`   | A catalog check id; `-` for all (default)       |
| `--run`           | Only run `<check>/<run id>` (or its full name)  |
| `--episode`, `-e` | Only this episode                               |
| `--verdict`       | `PASS`, `FAIL` or `UNKNOWN`                     |
| `--all-revisions` | Every run's findings, not only those that stand |
| `--limit`, `-n`   | Rows per page, 1-1000 (default 20)              |
| `--cursor`        | Pagination cursor, as the previous page printed |
| `--json`          | JSON output                                     |
