The AY-Robots recording tutorial showing how a LeRobot dataset is captured from an SO-100 teleoperation session, where the task description is set
language annotationsVLA trainingGR00T N1.7LeRobot datasettask descriptionSmolVLA

Language Annotations for VLA Training: The Task String

AY-Robots ResearchAugust 23, 202617 min read

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.

json
{"task_index": 0, "task": "cube into yellow bowl"}
{"task_index": 1, "task": "cube into green bowl"}
meta/tasks.jsonl from the cube_to_bowl_5 demo dataset in Isaac-GR00T. Two strings, and every frame references one of them by index.

Where the string lives depends on the dataset version

LayerLeRobot v2.0 / v2.1LeRobot v3.0
Instruction textmeta/tasks.jsonl, one JSON object per linemeta/tasks.parquet
Per-frame referencetask_index column in data/chunk-*/*.parquettask_index column, chunked parquet files
Episode listingmeta/episodes.jsonl with a tasks list per episodemeta/episodes/chunk-*/file-*.parquet
Read byGR00T N1.7, GR00T N1.5Pi0.5, SmolVLA, ACT
The version split is not cosmetic

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.

  1. 1
    Record 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.

    bash
    lerobot-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"
  2. 2
    Read 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.

    python
    import 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())
  3. 3
    Convert 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.

    bash
    uv 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
The AY-Robots glossary entry for the LeRobot dataset format, showing how episodes, task metadata and per-frame parquet columns fit together
The LeRobot dataset format at /glossary/lerobot-dataset. This is the layout the recorder just wrote: the instruction once in the task metadata, an integer per frame pointing at it.
The trap: a second session with a slightly different sentence

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..(.). The segments after the prefix are chosen by whoever built the dataset, which is why two well-known GR00T datasets use different keys for the identical thing. All three layers have to line up or nothing loads.

LayerFileSO-100 formLIBERO / SimplerEnv form
Parquet columndata/chunk-*/*.parquetannotation.human.task_descriptionannotation.human.action.task_description
modality.json keymeta/modality.json, under "annotation", prefix strippedhuman.task_descriptionhuman.action.task_description
Trainer modality_keysthe ModalityConfig you pass with --modality-config-pathannotation.human.task_descriptionannotation.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 not found in language modality.

json
{
  "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" }
  }
}
meta/modality.json from demo_data/cube_to_bowl_5 in Isaac-GR00T. The annotation entry has original_key set to task_index, so no new parquet column is needed at all.

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.

The failure that eats a day

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.

PolicyReads language?How the string is handledToken budget
GR00T N1.7YesAnnotation 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.5YesSame annotation-key mechanism, earlier backbone (Eagle, replaced in N1.7)not a trainer flag
Pi0.5Yestask.strip().replace("_", " ").replace("\\n", " "), then wrapped in a Task/State/Action prompt templatetokenizer_max_length 200, truncation on, padded to max_length (PaliGemma tokenizer)
SmolVLAYesA newline is appended if missing, then tokenised with HuggingFaceTB/SmolVLM2-500M-Video-Instructtokenizer_max_length 48, truncation on, padded to longest
ACTNoThere 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.

python
# 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"
Two policies, two different things done to the same sentence. Neither is in any training form; both are in the source.

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.

The AY-Robots policy comparison table listing GR00T N1.7, GR00T N1.5, Pi0.5, SmolVLA and ACT with parameters, GPU tier, inference latency and minimum episode counts
The five trainable policies at /policies. Four read the instruction. ACT does not, which is part of why it runs at 20 ms per action step against SmolVLA's 245 ms.

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.

InstructionVerdictWhy
test2UnusableNo information. The policy maps an arbitrary token to whatever motion is in the data.
UpUnusableNamed in the SmolVLA paper as the kind of vague command they had to replace.
so100_pick_place_v3PoorPi0.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 boxGoodVerb, coloured object, destination. Distinguishes itself from a blue-cube variant.
finish the ham cheese olives sandwichGoodFrom 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.

One fixed sentence per task, or several phrasings of the same task?
Keeping one exact sentence
  • 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.
Trade-offs you are accepting
  • 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.

FindingNumberSource
Success rate lost to paraphrased instructions22 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 range19.8 to 51.0 ppLIBERO-Para project page
Share of paraphrase failures that were trajectory divergence, not execution error80 to 96 percentLIBERO-Para abstract
GR00T N1.7 fine-tuned on LIBERO, original instructions195/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.

If your policy runs the wrong task, check the string before you blame the data

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.

bash
# 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"
The instruction is a runtime argument, not something baked into the checkpoint. Which means it is something you can get wrong at runtime.

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.

  1. Record with lerobot-record --dataset.single_task="...", and write the exact sentence somewhere that is not your shell history.
  2. Check codebase_version and total_tasks in meta/info.json. A larger total_tasks than you intended means a typo is now part of your dataset.
  3. For GR00T, convert v3.0 down to v2 with scripts/lerobot_conversion/convert_v3_to_v2.py.
  4. Copy a modality.json whose annotation key matches your data config into the dataset's meta/ directory: for SO-100, human.task_description with original_key: task_index, which is exactly what examples/SO100/modality.json ships.
  5. Fine-tune with examples/finetune.sh --modality-config-path examples/SO100/so100_config.py --embodiment-tag NEW_EMBODIMENT.
  6. Evaluate open-loop with gr00t/eval/open_loop_eval.py, then closed-loop with --lang_instruction set to the exact recorded string.
bash
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_finetune
The GR00T fine-tune launcher. Note the nesting: convert_v3_to_v2.py writes into <root>/<repo-id>, so --dataset-path has to carry the repo id as well - the SO100 README uses examples/SO100/finish_sandwich_lerobot/izuluaga/finish_sandwich. The language modality is configured in the Python file passed to --modality-config-path, not on the command line.
One reproducibility caveat

The 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.

A checklist before you launch the run

  1. Confirm codebase_version matches what your model wants and total_tasks matches what you intended.
  2. 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.
  3. For GR00T, diff the annotation block in meta/modality.json against the modality_keys in your data config. String equality is the whole check.
  4. Decide whether your policy reads the field at all. ACT on a two-task dataset gives one averaged behaviour however good the annotations are.
  5. 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.
  6. 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.
The AY-Robots desktop client download page, the client that records LeRobot-format datasets from a teleoperation session
The desktop client at /download. It writes the LeRobot layout described above, task metadata included.

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 guides
Do 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.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started