Push a dataset to New Theory
How an external tool uploads a LeRobot dataset into New Theory so newt finetune can train on it — the one-line CLI hand-off, and the raw HTTP sequence for a native integration.
This page is for engineers integrating an external tool — a recording kit, an exporter, a data pipeline — that produces a LeRobot dataset and needs to get it into New Theory so newt finetune can train on it. The developer authenticates with their own nt_ API key; no infrastructure credential ever leaves New Theory.
There are two doors, and they end in the same place — a dataset staged under the developer's account, referenced by name:
- The CLI door. Shell out to
newt finetune --dataset ./folder. One call uploads the local export and launches the run. Reach for this first. - The HTTP door. Sign,
PUT, verify, launch — the raw request sequence, for a native integration that doesn't want to depend on the CLI.
The CLI door
If your tool has the exported dataset folder on disk, the whole hand-off is one subprocess call. newt finetune detects that the argument is a local folder (it contains a path separator, or resolves to an existing directory), uploads it under the developer's account, and launches training against the staged name:
newt finetune --dataset ./datasets/my_taskThe CLI validates the export locally before a byte moves, uploads every file in one signing round-trip, then launches and watches the run:
Detected a local folder — uploading 214 MB… staged as my_task
… 342/342 files (214 MB / 214 MB) 100%
uploaded 214 MB, staged as my_task
Launched fine-tune on dataset 'my_task'.
job handle: <handle>
watch page: https://newtheory-console.vercel.app/runs/<handle>The staged name is the folder's basename (my_task above), so it must be a valid name: letters, digits, ., _, -, up to 128 characters. Rename the folder if it isn't.
The developer's key is read from NT_API_KEY, or from ~/.nt/credentials after newt login. Install the CLI with pip install "git+https://github.com/new-theory-research/newt-python.git".
If your integration is already Python and you want to upload without launching — for example, to stage a dataset now and let the developer launch later — call the upload primitive directly:
from newt.recording import NTCloudSink
sink = NTCloudSink("my_task", api_key="nt_…")
namespace = sink.upload_directory("./datasets/my_task")upload_directory validates the export, signs every file in one round-trip, PUTs each one, and writes a completeness marker last. It raises on any failure rather than leaving a partial upload that looks complete. This needs the recording extra: pip install "newt[recording]".
What the dataset must contain
Both doors upload a LeRobot v3 dataset directory. Training reads the dataset's schema from meta/info.json; the intake gate rejects a dataset that doesn't meet the contract before spending any GPU time.
The directory layout:
my_task/
meta/
info.json # schema: features, shapes, robot_type
tasks.parquet # the task strings, one per task index
stats.json # per-feature normalization stats (quantiles)
data/
chunk-000/
file-000.parquet # per-frame robot state + action
videos/
chunk-000/
observation.images.cam0/… # camera video, when features declare dtype "video"This is a standard LeRobot v3 export — the same layout lerobot writes and the Rerun kit produces. Training reads all of it: the frame data from data/, the camera video from videos/, the instructions from meta/tasks.parquet, and the normalization stats from meta/stats.json. Export it complete; upload it as-is.
meta/info.json is the contract. Its features map is what the intake gate checks:
{
"codebase_version": "v3.0",
"robot_type": "so101",
"features": {
"observation.images.cam0": {"dtype": "video", "shape": [480, 640, 3], "names": ["height", "width", "channels"]},
"observation.images.cam1": {"dtype": "video", "shape": [480, 640, 3], "names": ["height", "width", "channels"]},
"observation.state": {"dtype": "float32", "shape": [6], "names": ["state"]},
"action": {"dtype": "float32", "shape": [6], "names": ["action"]}
}
}Before it spends any GPU time, the intake gate reads meta/info.json and requires:
featurescontainsaction,observation.state, and at least oneobservation.images.*camera.actionandobservation.stateboth have shape[6]— the SO-101 joint dimension.- Cameras it can map to
cam0/cam1— it derives the rename from the dataset; a non-standard name is remapped, not rejected.
A dataset that misses the first two fails the run at gate: intake, with a message naming what tripped, before any GPU is spent. The gate also reads meta/tasks.parquet and rejects a dataset whose every episode's task string is empty, whitespace, or the exporter's placeholder.
The task strings must be real instructions, not the Rerun exporter's literal "task" placeholder — a policy trained on the placeholder learns nothing. Export with a real instruction (--task <instruction> when you export) and the right normalization stats (meta/stats.json with quantiles); the New Theory intake gate refuses a taskless dataset outright, so this is enforced twice.
The HTTP door
For a native integration, the sequence is four requests. Every request authenticates with the developer's key:
Authorization: Bearer nt_a1b2c3d4…A real key is nt_ followed by 40 characters. A missing or malformed key is 401. All requests are against the console at https://newtheory-console.vercel.app.
1. Sign
POST /api/uploads/sign mints one short-lived upload URL per file, in a single round-trip. Send the dataset name and the list of file paths, each relative to the dataset directory:
POST /api/uploads/sign
Authorization: Bearer nt_…
Content-Type: application/json
{
"dataset": "my_task",
"paths": [
"meta/info.json",
"meta/tasks.parquet",
"data/chunk-000/file-000.parquet",
"videos/chunk-000/observation.images.cam0/file-000.mp4"
]
}Response:
{
"namespace": "u_9f3c…",
"dataset": "my_task",
"count": 4,
"urls": [
{
"path": "meta/info.json",
"url": "https://storage.example/upload-url-issued-by-new-theory…",
"objectPath": "…",
"expiresAt": "2026-07-15T18:42:00.000Z"
}
]
}Each entry pairs the path you sent with the url to PUT it to. expiresAt is when the URL stops working — minutes, not hours, so sign and upload in the same pass. objectPath is New Theory's internal storage path for the object; you don't need it — the top-level namespace is the durable handle for the upload.
Constraints:
- At most 1000 paths per request. More than that is
400 {"error": "bad_request", "max_batch_paths": 1000}— split into multiple sign calls. The list is never silently truncated. - Each path is a
/-joined set of safe segments (letters, digits,.,_,-), up to 512 characters. No.., no leading/. A malformed path is400 {"error": "bad_request"}. - The
datasetname is one safe segment, up to 128 characters. - The upload lands under the key owner's namespace, derived server-side. You cannot name it, and one developer's key can never write into another's.
- The dataset name must not already exist in your namespace. Uploads are create-only — signing against a name you've already staged (complete or partial) is
409 {"error": "dataset_name_exists", ...}, and no URLs are minted. Re-upload under a new name, e.g.my_task_v2.
There is also a single-file shape — {"dataset": "…", "path": "…"} → {"url", "objectPath", "expiresAt"} — but the batch shape above is what you want for a dataset directory: one round-trip instead of one per file.
2. Upload
PUT each file's bytes to its signed url. The content type is fixed:
PUT <url>
Content-Type: application/octet-stream
<raw file bytes>Content-Type: application/octet-stream is required. It is bound into the URL's signature — a PUT with any other content type fails the signature check. No Authorization header goes on the PUT; the signed URL carries its own authorization. A 2xx means the object landed. Bytes never pass through the console — they go straight to storage over the signed URL.
3. Verify
GET /api/uploads/list?dataset=<name> lists what landed under the developer's account, so you can confirm every file arrived before launching:
GET /api/uploads/list?dataset=my_task
Authorization: Bearer nt_…{
"namespace": "u_9f3c…",
"count": 4,
"objects": [
{
"objectPath": "…",
"path": "my_task/meta/info.json",
"size": 4360,
"updated": "2026-07-15T18:41:12.000Z"
}
]
}Here path includes the dataset name (my_task/meta/info.json) — it is the object's path within the developer's namespace. Match count against the number of files you uploaded. The list is scoped to the key owner's namespace; a developer only ever sees their own uploads.
4. Launch
POST /api/finetune launches training against the staged dataset name and returns a job handle. Training runs on New Theory's GPUs under the developer's key:
POST /api/finetune
Authorization: Bearer nt_…
Content-Type: application/json
{ "dataset": "my_task" }{
"job_handle": "<handle>",
"dataset": "my_task",
"uid": "<model-uid>",
"status": "launched"
}Optional body fields:
| Field | Type | Meaning |
|---|---|---|
steps | integer, 2000–100000 | Total training steps. Out of range is 400 with a detail naming the bounds — never clamped. Omit for the server default. |
name | slug [a-z0-9-], 3–40 chars | Name for the model this run produces. A name already used under the account is 409 with a detail, and the run is not launched (no GPU spent). Omit to name the model after the dataset. |
5. Poll (optional)
GET /api/finetune/status?job_handle=<handle> reports the run's state. Poll it until status is terminal:
GET /api/finetune/status?job_handle=<handle>
Authorization: Bearer nt_…{ "status": "succeeded", "gate": null, "detail": null, "tag": "<model-tag>", "report_card": null, "model_status": "pending" }status is launched / running while the run is going, then succeeded or failed. On failed, gate names the step that stopped it (intake, train, frame-check, …) and detail carries the pipeline's human-readable failure cause. model_status reports the trained model's admission state — pending, probed, live, or dead. A succeeded run is not servable until admission reaches live; the tag resolves for inference only then. Training runs for hours — poll on a slow interval (every 15 seconds is what the CLI uses).
Errors
| Status | Where | Meaning |
|---|---|---|
401 | any | Key missing, malformed, or rejected. |
400 | sign | Bad dataset/path, or over 1000 paths (max_batch_paths in the body). |
409 | sign | dataset_name_exists — a dataset already exists under this name in your namespace; no URLs are minted (see detail). Upload under a new name instead. |
400 | finetune | dataset name invalid, or steps/name out of bounds (see detail). |
409 | finetune | name already used under this account — the run was not launched (see detail). |
| signature failure | PUT | Content type wasn't application/octet-stream, or the URL expired. |
503 | sign / finetune | A New Theory service is briefly unavailable — retry. |
End-to-end example
A complete native integration in Python, standard library plus requests. It uploads a dataset directory, verifies the upload, launches training, and polls to completion. Everything it needs is an nt_ key and a folder path.
import os
import sys
import time
from pathlib import Path
import requests
CONSOLE = "https://newtheory-console.vercel.app"
API_KEY = os.environ["NT_API_KEY"] # nt_ + 40 chars
DATASET = "my_task" # staged name (folder basename)
EXPORT_DIR = Path("./datasets/my_task") # LeRobot v3 dataset directory
AUTH = {"Authorization": f"Bearer {API_KEY}"}
def push_and_launch() -> str:
# 1. Collect every file, as paths relative to the dataset directory.
files = sorted(
p.relative_to(EXPORT_DIR).as_posix()
for p in EXPORT_DIR.rglob("*")
if p.is_file()
)
if not files:
sys.exit(f"no files under {EXPORT_DIR}")
# 2. Sign every file in one round-trip (up to 1000 per call).
signed = requests.post(
f"{CONSOLE}/api/uploads/sign",
headers={**AUTH, "Content-Type": "application/json"},
json={"dataset": DATASET, "paths": files},
)
signed.raise_for_status()
urls = {entry["path"]: entry["url"] for entry in signed.json()["urls"]}
# 3. PUT each file to its signed URL — content type MUST be octet-stream.
for rel in files:
with open(EXPORT_DIR / rel, "rb") as fh:
put = requests.put(
urls[rel],
data=fh.read(),
headers={"Content-Type": "application/octet-stream"},
)
put.raise_for_status()
print(f"uploaded {rel}")
# 4. Verify the whole set landed before launching.
listed = requests.get(
f"{CONSOLE}/api/uploads/list",
headers=AUTH,
params={"dataset": DATASET},
)
listed.raise_for_status()
landed = listed.json()["count"]
if landed < len(files):
sys.exit(f"only {landed}/{len(files)} files landed — not launching")
# 5. Launch training against the staged name.
launched = requests.post(
f"{CONSOLE}/api/finetune",
headers={**AUTH, "Content-Type": "application/json"},
json={"dataset": DATASET}, # add "steps"/"name" here to override defaults
)
launched.raise_for_status()
return launched.json()["job_handle"]
def watch(handle: str) -> None:
while True:
status = requests.get(
f"{CONSOLE}/api/finetune/status",
headers=AUTH,
params={"job_handle": handle},
).json()
state = status["status"]
if state == "succeeded":
model_status = status.get("model_status")
print(f"trained — model tag: {status.get('tag')} (admission: {model_status})")
if model_status != "live":
print("not servable yet — the tag resolves once admission reaches 'live'")
return
if state == "failed":
sys.exit(f"failed at gate: {status.get('gate')} — {status.get('detail')}")
print(f"… {state}")
time.sleep(15)
if __name__ == "__main__":
handle = push_and_launch()
print(f"launched: {handle}")
watch(handle)Where to go next
- CLI reference — every
newt finetuneflag, the--jsonoutput shape, and exit codes. - Rerun SO-101 hackathon kit — the record, calibrate, and export reference the dataset comes from.
SO-101 starter
What's in the newt-starter-so101 repo — the project you clone to run MolmoAct-2 SO-101 inference on a Hugging Face SO-101 arm.
WebSocket vs HTTP
Why the shipped inference API is a WebSocket stream instead of HTTP POST, what each transport gives up, and what the wire shape says about the product.