
How task descriptions become training signal: the GR00T annotation key, LeRobot task metadata, what a good instruction looks like, and the 22 to 52 pp a rephrase costs.
What you need to know
- •A LeRobot dataset stores each instruction once: the string lives in the task metadata, every data row carries a task_index.
- •GR00T reads an annotation key, not task_index, and that key has to match in three places: the parquet column, meta/modality.json and the modality config.
- •The SO-100 modality.json shipped with Isaac-GR00T avoids a new column entirely: original_key points the annotation key back at task_index.
- •Every policy mangles the string differently. Pi0.5 wraps it in a Task/Action template, SmolVLA appends a newline and truncates at 48 tokens, ACT has no text path at all.
- •LIBERO-Para (arXiv 2603.28301, March 2026) measured 22 to 52 percentage points of success lost to paraphrasing, across seven VLA configurations from 0.6B to 7.5B.
- •80 to 96 percent of those failures were the arm going somewhere else, not fumbling the grasp: the rephrase changed which task the model thought it was doing.
- •Practical rule: write the sentence you will send at inference, write it identically in every episode, keep it short, name the object.
What a language annotation actually is
Every LeRobot dataset already carries language whether you thought about it or not. When you record an episode, the recorder attaches a task string to every frame. LeRobot then deduplicates: the string is written once into the task metadata with an index, and each parquet row keeps only that integer. An eighty-episode dataset with two instructions holds two strings on disk and 70,000 integers pointing at them.
That indirection makes the annotation cheap to store and easy to get wrong. Nothing in the pipeline reads what you typed. Type test and the dataset is valid, the trainer accepts it, the loss falls, and you have taught a vision-language-action model that test means whatever motion you demonstrated.
{"task_index": 0, "task": "cube into yellow bowl"}
{"task_index": 1, "task": "cube into green bowl"}Where the string lives depends on the dataset version
| Layer | LeRobot v2.0 / v2.1 | LeRobot v3.0 |
|---|---|---|
| Instruction text | meta/tasks.jsonl, one JSON object per line | meta/tasks.parquet |
| Per-frame reference | task_index column in data/chunk-*/*.parquet | task_index column, chunked parquet files |
| Episode listing | meta/episodes.jsonl with a tasks list per episode | meta/episodes/chunk-*/file-*.parquet |
| Read by | GR00T N1.7, GR00T N1.5 | Pi0.5, SmolVLA, ACT |
The five policies on this platform do not agree on a dataset format. GR00T N1.7 and GR00T N1.5 want LeRobot v2.0 or v2.1; Pi0.5, SmolVLA and ACT want v3.0. A v3.0 dataset handed to the GR00T loader crashes, and the fix is a downconversion, not a flag. See the v3 rejection page and the dataset docs for the mechanics.
Where the string enters the dataset
On the manual path there is exactly one place the instruction is set: a flag on the recorder. This is the LeRobot recording command as it stands on lerobot main on 23 August 2026, and it is the flow the desktop client wraps when recording from a teleoperation session.
- 1Record with an explicit task string
The value is copied onto every frame of every episode in this invocation. There is no per-episode override: one run of the recorder produces one task.
bashlerobot-record \ --robot.type=so100_follower \ --robot.port=/dev/ttyACM0 \ --robot.id=my_follower \ --robot.cameras="{front: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}, wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}}" \ --teleop.type=so100_leader \ --teleop.port=/dev/ttyACM1 \ --teleop.id=my_leader \ --dataset.repo_id=$HF_USER/pick_red_cube \ --dataset.num_episodes=50 \ --dataset.single_task="Pick up the red cube and put it in the box" - 2Read back what actually landed on disk
Do this before you rent a GPU. total_tasks says how many distinct strings you ended up with, the task table says what they are. A v3.0 dataset has meta/tasks.parquet and no tasks.jsonl at all, so pick the right reader for your codebase_version.
pythonimport json, os, pandas as pd root = os.path.expanduser("~/.cache/huggingface/lerobot/USER/pick_red_cube") info = json.load(open(f"{root}/meta/info.json")) print(info["codebase_version"], info["total_episodes"], info["total_tasks"]) # v3.0 print(pd.read_parquet(f"{root}/meta/tasks.parquet")) # v2.1 instead: # print(open(f"{root}/meta/tasks.jsonl").read()) - 3Convert down if you are training GR00T
Isaac-GR00T ships the downconverter. It rewrites the v3.0 layout into the v2 layout the GR00T loader expects, task metadata included.
bashuv run --project scripts/lerobot_conversion \ python scripts/lerobot_conversion/convert_v3_to_v2.py \ --repo-id $HF_USER/pick_red_cube \ --root examples/SO100/pick_red_cube_lerobot

You record 30 episodes with --dataset.single_task="Pick up the red cube", break for lunch, then record 20 more with "pick up the red cube". Nothing errors. You now have a two-task dataset in which the two tasks are the same task, and the model will treat the capitalisation flip as signal. Check total_tasks in meta/info.json after every recording session.
The GR00T annotation key: three names that must agree
GR00T does not read the LeRobot task_index column by name. It reads an annotation key, and the naming convention is annotation.
| Layer | File | SO-100 form | LIBERO / SimplerEnv form |
|---|---|---|---|
| Parquet column | data/chunk-*/*.parquet | annotation.human.task_description | annotation.human.action.task_description |
| modality.json key | meta/modality.json, under "annotation", prefix stripped | human.task_description | human.action.task_description |
| Trainer modality_keys | the ModalityConfig you pass with --modality-config-path | annotation.human.task_description | annotation.human.action.task_description |
There is a third form in the wild. The pre-registered embodiment configs inside Isaac-GR00T use the short form for SO-100 and the two Unitree G1 entries, the long form for LIBERO, both SimplerEnv entries and RoboCasa Panda, and annotation.language.language_instruction for OXE DROID. None is more correct. What matters is that the key in your modality.json is the key your data config asks for. If it is not, the loader does not warn, it asserts: Key
{
"state": { "single_arm": {"start": 0, "end": 5},
"gripper": {"start": 5, "end": 6} },
"action": { "single_arm": {"start": 0, "end": 5},
"gripper": {"start": 5, "end": 6} },
"video": { "front": {"original_key": "observation.images.front"},
"wrist": {"original_key": "observation.images.wrist"} },
"annotation": {
"human.task_description": { "original_key": "task_index" }
}
}original_key is the most useful line in the schema and the easiest to miss: it lets the annotation key point at a column that already exists. A dataset from the normal recorder has a task_index column and nothing named annotation.anything, so rather than rewriting every parquet file to add a duplicate, you declare that the annotation key reads task_index. The shipped SO-100 and LIBERO modality.json files both do this.
You copy a modality.json from a LIBERO example into an SO-100 dataset. The index ranges look plausible, the video keys get renamed, the run starts. Then it dies in the dataloader on Key human.action.task_description not found in language modality - or worse, it does not die, because your data config asked for a key that happens to exist, and you fine-tune a 3B model on the wrong annotation channel. Diff the annotation block against the modality_keys in your data config before spending GPU hours: on the A100 and H100 tier that is 4 to 12 USD a run.
What each policy actually does with your sentence
The string does not reach the model as a string. A preprocessing chain sits between the dataset and the transformer, and each of the five policies on the policies page has a different one. Which one you are feeding changes how you should write the instruction.
| Policy | Reads language? | How the string is handled | Token budget |
|---|---|---|---|
| GR00T N1.7 | Yes | Annotation key resolved to text in the loader, then encoded by the VLM backbone (https://huggingface.co/nvidia/GR00T-N1.7-3B">Cosmos-Reason2-2B, Qwen3-VL architecture, per the model card) | not a trainer flag |
| GR00T N1.5 | Yes | Same annotation-key mechanism, earlier backbone (Eagle, replaced in N1.7) | not a trainer flag |
| Pi0.5 | Yes | task.strip().replace("_", " ").replace("\\n", " "), then wrapped in a Task/State/Action prompt template | tokenizer_max_length 200, truncation on, padded to max_length (PaliGemma tokenizer) |
| SmolVLA | Yes | A newline is appended if missing, then tokenised with HuggingFaceTB/SmolVLM2-500M-Video-Instruct | tokenizer_max_length 48, truncation on, padded to longest |
| ACT | No | There is no text path in the ACT model or config. The task string is stored and ignored. | none |
The Pi0.5 template explains two behaviours people report as bugs. Underscores in your task name silently become spaces, and the proprioceptive state is discretised into 256 bins and pasted into the same prompt. That state paste is on by default: the processor sets include_state_in_prompt = not use_proprioceptive_memory, and the Pi0.5 config defaults that flag to False while capping the tokenizer at 200 tokens. Your sentence is not the only thing competing for them.
# src/lerobot/policies/pi05/processor_pi05.py, lerobot main, 23 August 2026
cleaned_text = task.strip().replace("_", " ").replace("\n", " ")
if discretized_states is None:
full_prompt = f"Task: {cleaned_text};\nAction: "
else:
state_str = " ".join(map(str, discretized_states[i]))
full_prompt = f"Task: {cleaned_text}, State: {state_str};\nAction: "
# src/lerobot/processor/newline_task_processor.py - what SmolVLA does instead
if not task.endswith("\n"):
new_complementary_data["task"] = f"{task}\n"ACT deserves a plain statement, because it is the cheapest policy to train and the most misunderstood here. Grep modeling_act.py and its config for language and the only hit is the Apache licence header. ACT is a policy conditioned on images and joint state, nothing else. Record two tasks into one dataset, train ACT on it, and ACT averages them. That is not a language failure, it is a policy without language.

What a good instruction looks like
The SmolVLA paper is the best public evidence on what bad annotations look like, because its authors had to clean 481 community datasets into a pretraining mix of 22.9K episodes and 10.6M frames. They report substantial noise: ambiguous placeholders such as task desc, overly vague commands such as Hold or Up, and datasets that lacked instructions entirely. Their fix was to run Qwen2.5-VL-3B-Instruct over sampled frames and regenerate a short, action-oriented sentence per dataset, prompted for a maximum of 30 characters starting with a verb like Pick, Place or Open.
Thirty characters is a useful anchor: roughly the length of Pick up the cube and place it, well below SmolVLA's 48-token budget. The instruction is not where you put nuance. It is where you put the one distinguishing fact the policy cannot read off the camera.
| Instruction | Verdict | Why |
|---|---|---|
| test2 | Unusable | No information. The policy maps an arbitrary token to whatever motion is in the data. |
| Up | Unusable | Named in the SmolVLA paper as the kind of vague command they had to replace. |
| so100_pick_place_v3 | Poor | Pi0.5 turns the underscores into spaces and feeds the model 'so100 pick place v3'. It reads as a filename because it is one. |
| Pick up the red cube and put it in the box | Good | Verb, coloured object, destination. Distinguishes itself from a blue-cube variant. |
| finish the ham cheese olives sandwich | Good | From a real 80-episode public dataset. Its sibling task is 'finish the beef cheese lettuce sandwich': the ingredients are the only thing telling them apart. |
The sandwich example is a dataset you can open right now: izuluaga/finish_sandwich is 80 episodes, 70,277 frames and exactly two tasks, and the reference evaluation in Isaac-GR00T passes --lang_instruction="finish the ham cheese olives sandwich" at inference. Character for character. That is the discipline the mechanism depends on.
- Nothing to get wrong at inference: copy the string out of the task metadata into the eval command.
- Maximum signal per episode. With a 50-episode minimum for GR00T N1.7, splitting them across phrasings leaves fewer of each.
- Makes multi-task datasets legible. Two clean, distinct strings train a policy you can tell which task to run.
- Reproducible. One string across dataset, config and inference call is one fewer variable when a run goes wrong.
- You are fine-tuning on one point in language space. LIBERO-Para found 22 to 52 pp lost when that point moves.
- Anyone reusing your checkpoint has to know the magic sentence. Put it in the dataset card, not in your head.
- It does not survive an operator typing from memory. 'pick up the red cube' and 'Pick up the red cube and put it in the box' are different strings.
- You give up the generalisation the pretrained backbone was supposed to bring. RT-2 reported interpreting commands not present in the robot training data; a 50-episode fine-tune on one sentence does not preserve that.
What happens when the phrasing differs at inference
Until recently this was folklore. It is now measured. LIBERO-Para, published in March 2026, holds the task, the scene and the desired outcome constant and varies only the wording, across seven VLA configurations from 0.6B to 7.5B parameters. The abstract is blunt: consistent degradation of 22 to 52 percentage points under paraphrasing.
| Finding | Number | Source |
|---|---|---|
| Success rate lost to paraphrased instructions | 22 to 52 pp across 7 VLA configurations (0.6B to 7.5B) | LIBERO-Para abstract, arXiv 2603.28301 |
| Drop from object-name synonym substitution alone, for example stove to range | 19.8 to 51.0 pp | LIBERO-Para project page |
| Share of paraphrase failures that were trajectory divergence, not execution error | 80 to 96 percent | LIBERO-Para abstract |
| GR00T N1.7 fine-tuned on LIBERO, original instructions | 195/200, 195/200, 197/200 and 189/200 on Spatial / Goal / Object / Long (the README prints 97.65 / 97.5 / 98.45 / 94.35 percent) | Isaac-GR00T examples/LIBERO README, 8 GPUs, 20K steps, batch 640 |
Put the last row next to the first. A GR00T N1.7 fine-tune scores in the mid-to-high nineties on LIBERO using the benchmark's own strings, and the Spatial suite is ten variants of one pick-and-place whose descriptions differ only in the phrase locating the bowl: pick up the black bowl between the plate and the ramekin, next to the ramekin, on the cookie box. The sentence is what selects the target, so the model is clearly reading it. It is reading it as a lookup key, not as a description.
A second paper, from February 2026, arrives from a different direction. Testing SmolVLA and Pi0.5 on multi-object picking with progressively randomised placement, the authors report that in the harder regimes execution of the manipulation primitive stays substantially more reliable than instruction-conditioned task success, which they take as evidence that skill acquisition is decoupled from instruction following. They recommend reporting the two separately rather than as one success number. In plain terms: the arm can be good at picking things up and still be unreliable about picking the thing you named.
The 80 to 96 percent figure matters for debugging. A paraphrase failure does not look like a clumsy grasp, it looks like a confident, smooth motion to the wrong object. If you see that, compare your inference-time instruction to the dataset task metadata character for character before re-recording anything. The setup-sensitivity page and the loss-falls-but-nothing-happens page cover the neighbouring failure modes.
# Isaac-GR00T, examples/SO100/README.md - the inference-side flag
PROMPT="finish the ham cheese olives sandwich"
uv run --no-sync python eval_so100.py \
--robot.type=so101_follower \
--robot.port="$ROBOT_PORT" \
--robot.id="$ROBOT_ID" \
--policy_host=localhost \
--policy_port=5555 \
--lang_instruction="$PROMPT"Inside the GR00T Policy API the same value arrives as a nested observation dictionary with exactly three modalities: video, state and language. Language is passed as {"task": [[str]]}, shape (B, 1), a list of lists of strings, and the docs note it is normally a single timestep. Nothing validates that string against the checkpoint it is sent to.
Doing it by hand, or doing it here
Everything above runs today with two open-source repos and one GPU. The work is mostly bookkeeping, and the bookkeeping is where runs die.
- Record with
lerobot-record --dataset.single_task="...", and write the exact sentence somewhere that is not your shell history. - Check
codebase_versionandtotal_tasksinmeta/info.json. A larger total_tasks than you intended means a typo is now part of your dataset. - For GR00T, convert v3.0 down to v2 with
scripts/lerobot_conversion/convert_v3_to_v2.py. - Copy a modality.json whose annotation key matches your data config into the dataset's
meta/directory: for SO-100,human.task_descriptionwithoriginal_key: task_index, which is exactly whatexamples/SO100/modality.jsonships. - Fine-tune with
examples/finetune.sh --modality-config-path examples/SO100/so100_config.py --embodiment-tag NEW_EMBODIMENT. - Evaluate open-loop with
gr00t/eval/open_loop_eval.py, then closed-loop with--lang_instructionset to the exact recorded string.
CUDA_VISIBLE_DEVICES=0 NUM_GPUS=1 uv run bash examples/finetune.sh \
--base-model-path nvidia/GR00T-N1.7-3B \
--dataset-path examples/SO100/pick_red_cube_lerobot/$HF_USER/pick_red_cube \
--modality-config-path examples/SO100/so100_config.py \
--embodiment-tag NEW_EMBODIMENT \
--output-dir /tmp/so100_finetuneThe Isaac-GR00T fine-tune entry point is a tyro CLI that exposes no seed, so GR00T runs are not bit-for-bit reproducible. That matters when you are trying to attribute a behaviour change to an annotation change. lerobot's trainer does expose a seed, defaulting to 1000.
The platform removes the plumbing, not the judgement. The desktop client records LeRobot-format datasets out of a teleop session, the training form picks model, dataset and hyperparameters, and the backend rents a GPU by required VRAM, runs the trainer and writes checkpoints to object storage.
| Step | By hand | Here |
|---|---|---|
| Set the instruction | --dataset.single_task on the recorder | Typed once in the client when you start recording |
| Find a dataset to reuse | Browse the Hub and guess at annotation quality | /directory, or a Hugging Face repo id, or your own machine |
| Rent the GPU | Spot market, your account, your idle bill | Provisioned by required VRAM; A100/H100 tier runs 3 to 6 h at 1.20 to 2.00 USD/h |
| Serve the policy | Start a server, wire up a client | /api/inference/pod auto-provisions a pod with an idle watchdog that destroys itself |
What the platform does not do is check your sentence. No tool here reads your task string and tells you it is vague, and none paraphrase-augment your dataset. That judgement stays with whoever holds the leader arm. The same operations are available from the CLI and the MCP server if you would rather script it.

A checklist before you launch the run
- Confirm
codebase_versionmatches what your model wants andtotal_tasksmatches what you intended. - Print the task table and read every string out loud. If one is a filename, a version number or the word test, re-annotate before training.
- For GR00T, diff the
annotationblock inmeta/modality.jsonagainst themodality_keysin your data config. String equality is the whole check. - Decide whether your policy reads the field at all. ACT on a two-task dataset gives one averaged behaviour however good the annotations are.
- Copy the exact instruction into your inference command rather than retyping it, and put it in the dataset card if anyone else will drive the policy.
- Budget the episodes. The platform minimum is 50 for GR00T N1.7, GR00T N1.5 and Pi0.5, 30 for SmolVLA, and that is per run, not per instruction.
Where this does not help
A language annotation is a conditioning input, not a specification. It cannot fix inconsistent demonstrations, and it does not make an imitation learning run generalise to an object you never showed it. The 22 to 52 pp paraphrase gap is a property of small-data fine-tuning, and no care in writing the sentence closes it on 50 episodes.
- It does not affect inference latency noticeably. The 20 to 485 ms per action step is set by the architecture, not your sentence.
- It does not make remote inference viable for fast reactive motion. Public-internet round trips on top of a 152 ms GR00T step turn a working policy into a hesitant one, whatever the instruction says.
- Rich, compositional instructions of the kind RT-2 demonstrated come from internet-scale co-training, not a 50-episode fine-tune on your bench.

Train on your own annotations
Pick a model and an arm and get the guide for that combination: dataset format, the defaults the trainer really sends, and what a run costs.
Open the training guidesDo I have to add an annotation column to my parquet files for GR00T?▾
No. Set original_key to task_index in the annotation block of meta/modality.json and the loader will resolve the existing LeRobot column. That is what the shipped SO-100 and LIBERO modality.json files in Isaac-GR00T do.
What happens if I use a different sentence at inference than the one I recorded?▾
Measurably worse performance. LIBERO-Para (arXiv 2603.28301, March 2026) reports 22 to 52 percentage points of success rate lost under paraphrasing across seven VLA configurations, with object-name synonym substitution alone costing 19.8 to 51.0 pp. Between 80 and 96 percent of those failures were the arm executing a different task cleanly, not failing the motion.
Does ACT use the task description at all?▾
No. The ACT model and config in lerobot contain no tokenizer and no language input. The task string is stored in the dataset and ignored during training and inference. If you need one policy to distinguish two instructions, ACT is the wrong choice; SmolVLA at about 450M parameters is the cheapest one that reads language.
How long should the instruction be?▾
Short. The SmolVLA authors prompted Qwen2.5-VL-3B-Instruct to regenerate community annotations at a maximum of 30 characters, starting with an action verb such as Pick, Place or Open. SmolVLA's tokenizer_max_length is 48 tokens and Pi0.5's is 200, both with truncation on, but Pi0.5 also pastes 256-bin discretised proprioception into the same prompt by default. Verb, object, destination is usually enough.
Why do LIBERO datasets use annotation.human.action.task_description?▾
Because the segments after the annotation. prefix are chosen by the dataset author. Isaac-GR00T's data preparation guide lists both forms as valid and tells you to match the exact key your dataset uses. In the shipped embodiment configs, SO-100 and the two Unitree G1 entries use the short form; LIBERO, both SimplerEnv entries and RoboCasa Panda use the long one; and OXE DROID uses annotation.language.language_instruction.
Related reading
The RT-2 walkthrough covers how actions became text tokens, the VLA overview puts the five policies in context, and the data collection guide covers the demonstrations these annotations attach to. Benchmarks for 85 models live in the arena; the SO-100 page covers the arm.
Sources
- Isaac-GR00T data preparation: the annotation.<source>.<type>(.<name>) convention and the three places it must agree
- Isaac-GR00T demo_data/cube_to_bowl_5 modality.json: human.task_description with original_key task_index
- Isaac-GR00T lerobot_episode_loader.py: annotation key resolution and the "not found in language modality" assertion
- Isaac-GR00T embodiment_configs.py: the language modality key per pre-registered embodiment
- Isaac-GR00T SO100 example: v3-to-v2 conversion, finetune.sh, and --lang_instruction at eval
- Isaac-GR00T LIBERO example: GR00T N1.7 success counts per suite and the full task list
- Isaac-GR00T Policy API guide: the three-modality observation dict and the (B, 1) language field
- lerobot_record.py: --dataset.single_task and the per-frame task key
- lerobot Pi0.5 processor: task cleaning, 256-bin state discretisation and the Task/State/Action prompt
- lerobot Pi0.5 config: tokenizer_max_length 200, use_proprioceptive_memory False
- lerobot SmolVLA config: tokenizer_max_length 48, pad_language_to longest, SmolVLM2-500M-Video-Instruct
- lerobot NewLineTaskProcessorStep: appending a newline to the task string
- LIBERO-Para: A Diagnostic Benchmark and Metrics for Paraphrase Robustness in VLA Models, Kim et al., 30 March 2026
- LIBERO-Para project page: 4,000+ paraphrases, 43 variation types, per-model drops and the PRIDE metric
- SmolVLA: A Vision-Language-Action Model for Affordable and Efficient Robotics, Shukor et al., 2 June 2025
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started