Skip to content

Use Qualia from an agent

An agent uses Qualia with an API key, through the CLI or the SDK. A key acts as the person who made it, inside one organization. It can do what that person’s role allows, and its scopes can only narrow that.

This page walks the path from an upload to an export. Then it covers finding your way around, admin work, and what a key cannot do. Each step shows the CLI first, then the same work in Python. The command reference and the SDK pages list every option.

Pass --json for output a script can read. A command that fails exits 1 with the reason on stderr. A command used wrongly exits 2.

Make a key in the dashboard: open Settings, then Account › API keys, click New key, then Create key. Copy the key: it is shown once. Keys expire after 90 days by default. No key can make another key.

The key acts in the workspace it was made in. QUALIA_WORKSPACE picks another workspace you can reach. The reference pages call it an organization key. Every step on this page works with it.

A key restricted to some workspaces (a workspace key) reaches much less for now. It lists only its own workspaces, and it is refused on datasets, uploads, tasks, checks, activity and running work. It cannot change or delete a workspace.

A key carries every scope except three admin scopes, so it can upload and curate as soon as it exists. The three are members:read, members:write and integrations:write. None is on by default. Open More options and tick one under Admin (opt-in) when you make the key. A key with * does not get them. See Admin scopes.

Give the key to the CLI once:

Terminal window
qualia auth login --base-url https://dev-api.qualiastudios.dev
qualia auth status

qualia auth status shows what is stored. It does not call the API, so the first command that does is the one that proves the key works. Every command acts in the key’s own account, so none needs an account id (Your account).

An agent usually gets its key from the environment instead. Set QUALIA_API_KEY and QUALIA_BASE_URL together, because the CLI ignores its config file once the key is set. QUALIA_WORKSPACE picks the workspace to act in, if it is not the key’s own. Scripts and CI has the details.

The SDK reads the same variables:

from qualia import Qualia
client = Qualia()
Terminal window
qualia dataset upload ./session-0412 session-0412 --standalone --json > receipt.json
DATASET=$(jq -r .dataset_id receipt.json)
qualia dataset ingest-report "$DATASET"

The later steps reuse $DATASET. The upload’s exit code says what it came to: 0 published, possibly with flagged episodes; 1 it failed or was refused; 3 the bytes are kept but no episode could be derived. qualia dataset upload lists the formats.

Exit 1 does not mean nothing is left. A transfer that fails partway stays in the upload feed as failed, and the bytes it stored stay too. Read the feed before you try again: qualia dataset uploads works from any machine. cancel-upload stops an upload in flight. dismiss-upload takes a failed one off the feed and leaves its bytes.

--standalone keeps the upload on its own. Without it, a machine with a saved destination appends the upload to that merged dataset. The upload is then part of that dataset, and the server refuses the steps below on it.

receipt = client.data.upload("./session-0412", name="session-0412", standalone=True)
dataset = receipt["dataset_id"]
report = client.datasets.ingest_report(dataset)

See data and datasets.

Terminal window
ROBOT=$(qualia robot create lab-arm --urdf lab_arm.zip --json | jq -r .name)
qualia robot model "$ROBOT"
qualia robot configure "$ROBOT" --solver dls
GRIPPER=$(qualia gripper create parallel_gripper.urdf --json | jq -r .name)
qualia gripper tcp "$GRIPPER" tcp.json
qualia robot carry "$ROBOT" "$GRIPPER"
qualia dataset update "$DATASET" --target-robot "$ROBOT"

qualia robot create with --urdf imports the file and waits for it. qualia robot model shows the arms it guessed, and qualia robot configure corrects them. qualia gripper tcp says where the tool centre point is. qualia robot carry gives the robot its own copy of the gripper, so change the copy from then on. qualia dataset update picks the robot the dataset plays on.

robot = client.robots.create_from_urdf("lab_arm.zip")
model = client.robots.get_model(robot.name)
client.robots.update_model(robot.name, solver="dls", etag=model.etag)
gripper = client.robots.create_from_urdf("parallel_gripper.urdf", kind="gripper")
client.robots.set_gripper_tcp(gripper.name, file="tcp.json")
client.robots.set_gripper(robot.name, gripper.name)
client.datasets.update(dataset, target_robot=robot.name)

See robots.

A task says what the robot should do and how success shows. Publish it, then link the upload to it.

Terminal window
TASK=$(qualia task create --name "Cube in box" -d "Pick the cube off the table and put it in the box" --json | jq -r .name)
qualia task edit "$TASK" --success "The cube rests inside the box"
qualia task publish "$TASK" -m "First revision"
qualia dataset link-task "$DATASET" "$TASK"

qualia task has the rest.

task = client.tasks.create("Cube in box")
client.tasks.update(
task.name,
description="Pick the cube off the table and put it in the box",
success="The cube rests inside the box",
)
client.tasks.publish(task.name, note="First revision")
client.datasets.update(dataset, linked_task=task.name)

See tasks.

A check never keeps, rejects or deletes an episode. It raises issues and notes. The catalog’s checks use a model and cost credits, so see their bound first: --validate-only starts nothing.

Terminal window
qualia dataset checks list "$DATASET"
qualia dataset checks run "$DATASET" --all --validate-only
qualia dataset checks run "$DATASET" --all --max-cost 200 --wait
qualia dataset checks findings "$DATASET" --verdict FAIL
qualia dataset issues summary "$DATASET"

See qualia dataset checks and qualia check, which also reads check runs, reference ranges and the reach check.

started = client.checks.run(dataset, all=True, max_cost=200)
run = client.checks.wait(started.check_run)
findings = client.checks.list_findings(dataset, verdict="FAIL")

See checks.

Read what is there, then rule. Pick episodes by id or with the filter the dataset page’s filter bar writes.

Terminal window
qualia dataset summary "$DATASET" --facet state --facet open_issue_codes
EPISODE=$(qualia dataset episodes "$DATASET" --filter 'state = UNREVIEWED' -n 1 --json | jq -r '.episodes[0].name')
qualia dataset keep "$DATASET" --filter 'state = UNREVIEWED AND open_issue_count = 0' --reason "no issues"
qualia dataset reject "$DATASET" --filter 'metrics.duration_seconds < 2' --reason "too short"
qualia dataset reset "$DATASET" "$EPISODE"

keep and reject never delete anything, and reset withdraws them. A take a person has open in the studio is left as it was: the command lists it and still exits 0. Run it again once they are done.

Issues are settled apart from rulings:

Terminal window
qualia dataset issues list "$DATASET" --filter 'state = OPEN AND blocks = true'
qualia dataset issues dismiss "$DATASET" --filter 'code = "idle_tail"' --reason "fine for this task"
qualia dataset issues add "$DATASET" "Gripper slips at the second grasp" -e "$EPISODE" --at 12.5

See qualia dataset issues.

Edits change the take, not a ruling. qualia dataset trim --clear restores a trim, and qualia dataset unsplit removes what a split made. --dry-run plans a split and writes nothing.

Terminal window
qualia dataset edit "$EPISODE" -d "$DATASET" --outcome SUCCESS
qualia dataset trim "$EPISODE" -d "$DATASET" --start 1.5 --end 12
qualia dataset split "$EPISODE" -d "$DATASET" --keep 0.5-6 --keep 8-14 --dry-run

See qualia dataset edit, trim and split.

page = client.episodes.list(dataset, filter="state = UNREVIEWED", limit=20)
episode = page.items[0].name
client.episodes.keep(
dataset, filter="state = UNREVIEWED AND open_issue_count = 0", reason="no issues"
)
client.episodes.reject(dataset, [episode], reason="operator in frame")
client.episodes.reset(dataset, [episode])
client.issues.dismiss(dataset, filter='code = "idle_tail"', reason="fine for this task")
client.episodes.update(episode, dataset=dataset, outcome="SUCCESS")

See episodes and issues.

A version freezes what the dataset yields. Training and downloads read versions, never the live dataset. Read the diff first, then tag the head you reviewed: the tag is refused if the head moved since.

Terminal window
DIGEST=$(qualia dataset version diff "$DATASET" --json | jq -r .targetDigest)
qualia dataset version create "$DATASET" 1.0 --note "first cut" --digest "$DIGEST"
qualia dataset version list "$DATASET"

Tags never move. qualia dataset pin makes the same tag and also freezes a dataset definition. See qualia dataset version.

diff = client.versions.compare(dataset)
tag = client.versions.create(dataset, "1.0", note="first cut", digest=diff.target_digest)

See versions.

Terminal window
qualia dataset download "$DATASET:1.0" ./session-0412-1.0

The CLI checks every file against its size and sha256, shows progress on stderr, and creates the folder only once the whole tree is there. A run that stops picks up where it left off. 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 need read access only (data:read).

--format takes lerobot_v3 (the default), lerobot_v2_1, mcap_ros2 or original (every file uploaded to the dataset, byte for byte; no version, and an organization key for now). See qualia dataset download and Download a version.

result = client.data.download(f"{dataset}:1.0", "./session-0412-1.0")
To seeCLISDK
Workspacesqualia project listclient.projects.list()
One workspacequalia project showclient.projects.get(project_id)
What people and keys didqualia activity listclient.activity.list()
Running workqualia status listclient.statuses.list()
Stop running workqualia status cancelclient.statuses.cancel(name)
Terminal window
qualia activity list --org --filter 'verb = KEEP AND actor = API_KEY' --json
qualia status list --running --json

Admin work needs an opt-in scope ticked when the key was made. The scope opens the door. The role of the key’s creator still decides: a Contributor’s key with members:write cannot change a role, and no key grants a role above its creator’s own. A guest’s key can do none of this. Activity records each write with the key’s name. credits:write is an ordinary scope that every new key holds.

An integration key’s value goes in once and is never shown again, so pipe it in and keep it out of your shell history:

Terminal window
printf %s "$HF_TOKEN" | qualia integration add huggingface --name ci --secret-stdin

These stay in the dashboard:

  • Log in or sign up. A person makes the key.
  • Reset a password. That is the person’s.
  • Change appearance. Theme is a browser setting.
  • See presence. Who is on a page is shown to signed-in people.
  • Hold an edit lease. A person editing a take blocks a key’s write to it.
  • Buy credits. Stripe checkout needs a person. A key can only redeem a voucher.
  • Create or delete an organization. A person does that.

sdk/cli/examples/agent_path.sh, in the CLI’s source, runs this path with --json against the stack named by QUALIA_BASE_URL and QUALIA_API_KEY. It makes its own data, prints a line per step, and exits non-zero if a step fails. Where a stack cannot finish a step, it says so.

On exit it deletes what it made and cancels an upload it did not finish. An upload’s repository and stored bytes stay, because no command deletes them. The script prints a line for each one.