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

# Training runs: fine-tune a policy on managed GPUs

> Train robot policies programmatically — point Kite at a LeRobot dataset, pick your policies and a GPU tier, and get back trained checkpoints.

A **training run** fine-tunes one policy on one GPU from a LeRobot dataset. You choose the dataset, the policy types, and the GPU tier; Kite provisions the hardware, runs the training, streams you progress, and saves checkpoints.

One run trains one policy. A single request can start several — pass more than one entry in `policies` and you get one run back per policy, all training in parallel on their own GPUs so you can compare them.

## Create a run

```bash theme={"system"}
curl -X POST https://api.kiteml.com/v1/training_runs \
  -H "Authorization: Bearer $KITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "dataset": { "uri": "lerobot/pusht" },
    "policies": ["act"],
    "hardware_tier": "gcp_gpu_t4",
    "config": { "steps": 20000 }
  }'
```

### Request body

<ParamField body="dataset.uri" type="string" required>
  The dataset to train on: a Hugging Face LeRobot repo id such as `lerobot/pusht`, or a `gs://` path to a LeRobot dataset.
</ParamField>

<ParamField body="policies" type="string[]" required>
  Policy types to train, one run each. Between `1` and `8` entries. Fetch the list from [`GET /v1/training_policies`](#discover-policies-and-gpus).
</ParamField>

<ParamField body="hardware_tier" type="string">
  The GPU to train on. Defaults to `gcp_gpu_t4`. Each policy declares a minimum tier — see [`GET /v1/hardware_tiers`](#discover-policies-and-gpus).
</ParamField>

<ParamField body="config.steps" type="integer">
  Training steps. Defaults to the policy's own recommended value.
</ParamField>

<ParamField body="config.batch_size" type="integer">
  Batch size. Defaults to the policy's own recommended value.
</ParamField>

<ParamField body="config.save_freq" type="integer">
  Save a checkpoint every N steps.
</ParamField>

<ParamField body="cameras.rename" type="object">
  Maps your dataset's camera keys to the policy's image slots: each key is a camera key from your dataset, each value the slot to feed it into. Omit it and Kite maps them for you. See [Inspect a dataset](#inspect-a-dataset) for your dataset's camera keys and a worked example.
</ParamField>

<ParamField body="output.push_to_hub" type="boolean">
  Push the trained policy to your connected Hugging Face account when the run finishes. Defaults to `false`.
</ParamField>

<ParamField body="output.hf_repo_id" type="string">
  The policy name to push under. The namespace is always your connected account.
</ParamField>

<ParamField body="webhook_metadata" type="object">
  Opaque data echoed back on the run and on its webhook events. Use it to tie a run to your own job id.
</ParamField>

The call returns one resource per policy, immediately:

```json Response — 202 Accepted theme={"system"}
{
  "object": "list",
  "data": [
    {
      "id": "trn_01J8X4M2K9ZQ6R7T3V5W8Y0B1C",
      "object": "training_run",
      "status": "processing",
      "phase": "provisioning",
      "progress": 0.0,
      "policy": "act",
      "hardware_tier": "gcp_gpu_t4",
      "group_id": "5f2c9a1b4e7d",
      "dataset": { "uri": "lerobot/pusht" },
      "tokens": { "charged": 500, "refunded": 0 },
      "created_at": "2026-07-30T09:14:00Z"
    }
  ]
}
```

Runs started in the same request share a `group_id`, so you can tell which ones were launched together even if their webhooks arrive out of order.

Common failures at create time:

* `400 policy_not_available` — an unknown policy, or one that can't currently be trained
* `400 hardware_tier_too_small` — the tier is below the policy's minimum; `details.min_hardware_tier` tells you what it needs
* `400 hardware_tier_not_available` — an unknown or currently disabled tier; `details.available_tiers` lists the usable ones
* `402 insufficient_tokens` — not enough credits for the whole request
* `429 concurrency_limit_exceeded` — you already have the maximum number of runs in flight
* `429 capacity_exceeded` — Kite is at GPU capacity; retry shortly
* `503 service_unavailable` — cloud GPU training is temporarily down

Validation covers the whole request before anything launches, so a request either starts all of its runs or none of them.

See [Authentication → Errors](/platform-api/authentication#errors) for the envelope.

### Idempotency

Pass a unique `Idempotency-Key` header to make retries safe. A repeated request with the same key returns the original runs instead of starting duplicates — so a dropped connection or a CI retry never double-charges you.

```bash theme={"system"}
curl -X POST https://api.kiteml.com/v1/training_runs \
  -H "Authorization: Bearer $KITE_API_KEY" \
  -H "Idempotency-Key: 9f1c8e2a-run-42" \
  -H "Content-Type: application/json" \
  -d '{ "dataset": { "uri": "lerobot/pusht" }, "policies": ["act"] }'
```

<Note>
  Reusing a key with a *different* payload returns `409 Conflict` — the key is bound to the first request body it saw.
</Note>

## Track progress

Poll the run to watch it move through its lifecycle. `progress` and `metrics` come straight from the running trainer.

```bash theme={"system"}
curl https://api.kiteml.com/v1/training_runs/trn_01J8X4... \
  -H "Authorization: Bearer $KITE_API_KEY"
```

```json theme={"system"}
{
  "id": "trn_01J8X4M2K9ZQ6R7T3V5W8Y0B1C",
  "object": "training_run",
  "status": "processing",
  "phase": "training",
  "progress": 0.42,
  "status_message": "Training",
  "metrics": { "step": 8400, "max_steps": 20000, "loss": 0.31 },
  "output": { "checkpoint_count": 8, "latest_checkpoint": { "step": 8000 } }
}
```

The `status` field moves through:

| Status       | Meaning                                         |
| ------------ | ----------------------------------------------- |
| `processing` | The run is provisioning, preparing, or training |
| `succeeded`  | Training finished and checkpoints are saved     |
| `failed`     | The run stopped before completing (see `error`) |
| `canceled`   | You canceled the run                            |

`phase` tells you *what* a `processing` run is doing right now — useful, because provisioning a GPU and training on it both look the same from `status` alone:

| Phase                 | Meaning                                    |
| --------------------- | ------------------------------------------ |
| `provisioning`        | Waiting for a GPU and pulling the image    |
| `downloading_dataset` | Fetching your dataset                      |
| `downloading_weights` | Fetching the pretrained base model         |
| `loading_model`       | Building the policy                        |
| `computing_stats`     | Computing dataset normalization statistics |
| `preparing_optimizer` | Setting up the optimizer                   |
| `training`            | Training, with live `metrics`              |
| `saving`              | Writing the final checkpoint               |
| `pushing_to_hub`      | Uploading to Hugging Face                  |

<Tip>
  Poll every few seconds while a run is active, or register a [webhook](/platform-api/api-reference) and skip polling. Kite emits `training_run.completed`, `training_run.failed`, and `training_run.canceled`.
</Tip>

You can also poll any run through the uniform operations view, which works the same for every Kite resource:

```bash theme={"system"}
curl https://api.kiteml.com/v1/operations/trn_01J8X4... \
  -H "Authorization: Bearer $KITE_API_KEY"
```

## Read the logs

```bash theme={"system"}
curl "https://api.kiteml.com/v1/training_runs/trn_01J8X4.../logs?tail=200" \
  -H "Authorization: Bearer $KITE_API_KEY"
```

Returns the last `tail` lines of the trainer's output, capped at 1000. Logs stay readable after the run finishes, so this is where you look when a run fails.

## Get your policy

When `status` is `succeeded`, `output` describes what the run produced.

```json Response — output theme={"system"}
"output": {
  "checkpoint_count": 20,
  "latest_checkpoint": {
    "object": "training_checkpoint",
    "step": 20000,
    "artifact_uri": "gs://.../artifacts/lerobot/checkpoints/020000/pretrained_model/",
    "download_url": "/v1/training_runs/trn_01J8X4.../checkpoints/20000/download"
  },
  "hf_repo_id": "your-account/act-pusht",
  "hf_url": "https://huggingface.co/your-account/act-pusht",
  "hf_push_status": "ok"
}
```

Only the latest checkpoint is summarised inline — list them all at `/checkpoints`. `artifact_uri` is the raw storage path, useful if you have your own access to the bucket; otherwise use `download_url`.

## Download a checkpoint

List every checkpoint a run saved, oldest first:

```bash theme={"system"}
curl "https://api.kiteml.com/v1/training_runs/trn_01J8X4.../checkpoints?limit=20" \
  -H "Authorization: Bearer $KITE_API_KEY"
```

Then fetch one as a ZIP of its `pretrained_model/` directory:

```bash theme={"system"}
curl -L -o act_step20000.zip \
  https://api.kiteml.com/v1/training_runs/trn_01J8X4.../checkpoints/20000/download \
  -H "Authorization: Bearer $KITE_API_KEY"
```

It unzips to a standard LeRobot checkpoint — the same files the dashboard gives you, and the same ones `push_to_hub` uploads. Kite streams the archive rather than building it up front, so a multi-GB policy downloads like any other file.

Checkpoints are paginated on `step`: pass the response's `next_cursor` back as `after`, and stop when `has_more` is false. A run with a small `save_freq` can have thousands of them.

<Tip>
  `output.push_to_hub` is the other route: set it at create time and the finished policy lands in your Hugging Face account automatically, no download step.
</Tip>

<Check>
  The result is a standard **LeRobot** policy checkpoint. It's the same artifact the dashboard produces, so it loads anywhere LeRobot policies load. No proprietary output format, no lock-in.
</Check>

## Cancel a run

Stop a run at any time. Cancelling releases the GPU immediately.

```bash theme={"system"}
curl -X POST https://api.kiteml.com/v1/training_runs/trn_01J8X4.../cancel \
  -H "Authorization: Bearer $KITE_API_KEY"
```

Cancelling a run that has already finished returns `409 not_cancelable`.

## Discover policies and GPUs

Rather than hard-coding names, fetch what's currently trainable. Both lists are small and never paginate.

```bash theme={"system"}
curl https://api.kiteml.com/v1/training_policies \
  -H "Authorization: Bearer $KITE_API_KEY"
```

Each policy reports its `min_hardware_tier`, `min_vram_gb`, and recommended `default_steps` and `default_batch_size`. A policy with `"available": false` can't be trained right now.

```bash theme={"system"}
curl https://api.kiteml.com/v1/hardware_tiers \
  -H "Authorization: Bearer $KITE_API_KEY"
```

Each tier reports its GPU and its `tokens_per_hour` rate. A tier with `"available": false` exists but is switched off.

## Inspect a dataset

Check a dataset is trainable — and get its camera keys — before spending any credits.

```bash theme={"system"}
curl -X POST https://api.kiteml.com/v1/datasets/inspect \
  -H "Authorization: Bearer $KITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "uri": "lerobot/pusht" }'
```

```json theme={"system"}
{
  "object": "dataset_inspection",
  "uri": "lerobot/pusht",
  "trainable": true,
  "camera_keys": ["observation.images.top", "observation.images.wrist"],
  "action_dim": 7,
  "state_dim": 14,
  "fps": 30,
  "num_episodes": 50,
  "total_frames": 12000,
  "issues": []
}
```

`camera_keys` is what `cameras.rename` maps from. To feed those two cameras into a policy's `top` and `wrist` slots:

```json theme={"system"}
"cameras": {
  "rename": {
    "observation.images.top": "observation.images.cam_high",
    "observation.images.wrist": "observation.images.cam_low"
  }
}
```

An unreadable dataset returns `400 dataset_not_readable`.

## What a run costs

Every run reserves one GPU-hour of credits up front, at its tier's rate. A run that trains for longer than an hour is not charged extra.

| Tier           | GPU         | Tokens per hour |
| -------------- | ----------- | --------------- |
| `gcp_gpu_t4`   | NVIDIA T4   | 500             |
| `gcp_gpu_l4`   | NVIDIA L4   | 1,000           |
| `gcp_gpu_a100` | NVIDIA A100 | 5,000           |

A request that starts several runs reserves for each of them, so training three policies on a T4 costs 1,500 tokens. A run whose GPU never starts — a submission failure — is not charged at all.

Preview the cost before committing:

```bash theme={"system"}
curl -X POST https://api.kiteml.com/v1/training_runs/estimate \
  -H "Authorization: Bearer $KITE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "hardware_tier": "gcp_gpu_a100", "policies": 2 }'
```

Runs started through the API bill from the same credit balance as the dashboard, and appear in [`GET /v1/usage`](/platform-api/api-reference) under `training`.

## List your runs

```bash theme={"system"}
curl "https://api.kiteml.com/v1/training_runs?limit=20" \
  -H "Authorization: Bearer $KITE_API_KEY"
```

Returns your API-created runs, newest first. Pass the response's `next_cursor` as `after` to fetch the next page.

<Note>
  Runs you start through the API also appear in the **Training** section of the [dashboard](https://app.kiteml.com), alongside the ones you start there. It's one set of runs on one set of GPUs, whichever way you launch them.
</Note>
