
How the task string in a LeRobot dataset reaches a VLA model, how phrasing changes what the arm does, and exactly what breaks when training and inference instructions drift apart.
A vision-language-action model takes three things on every control step: camera frames, joint positions, and a string of text. Two of those come from hardware you can measure and calibrate. The third is typed by a person once, at recording time, and then typed again later, possibly by a different person, when the policy is actually driving the arm. It is the only input to the model that nobody checks.
That string is not a caption and it is not documentation. It is tokenized, embedded, and concatenated with the image tokens before the action head runs. Change the wording and you change the input tensor, which changes the actions. This article traces where the instruction lives in a LeRobot dataset, what each of the five policies you can train on this platform does with it, how phrasing shifts behaviour, and the specific ways training-time and inference-time instructions drift apart. Everything about upstream repos below was read from the source on 2026-08-24 and is dated where it matters.
What you need to know
- •The words are stored once per unique string in the dataset metadata and every frame carries only an integer index. LeRobot v3.0 keeps that table in meta/tasks.parquet; the v2.1 layout GR00T reads keeps it in meta/tasks.jsonl.
- •SmolVLA truncates the instruction at 48 tokens without saying so. Pi0.5 allows 200. Pi0 allows 48. GR00T sets no cap in its fine-tune config.
- •ACT has no language input at all. On ACT the instruction is inert, and a multi-task dataset just becomes a confused single-task dataset.
- •lerobot-rollout has a --task flag that defaults to an empty string. Forgetting it does not raise an error and a base rollout does not print the task, so the policy runs on a blank prompt in silence.
- •openpi's DataConfig.prompt_from_task defaults to False. If you write your own fine-tune config and leave it alone, the dataset task field never reaches the model.
- •GR00T needs the annotation key to match in three places at once: the parquet column, meta/modality.json, and the modality config. A mismatch is an assertion failure, not a warning.
The instruction is a model input, not a label
Three ecosystems, three names for the same tensor. In LeRobot the field is called task. In NVIDIA's Isaac-GR00T it is an annotation channel, canonically annotation.human.task_description. In Physical Intelligence's openpi it is a field called prompt. All three end up in the same place: a token sequence that the vision-language backbone attends over while producing an action chunk.
This matters for fine-tuning because the instruction is part of what the model conditions on, not part of what it is scored against. If every episode in your dataset carries the same string, the model learns that this string means the whole behaviour, and it will happily produce that behaviour for anything close to it. If your episodes carry five different strings for what is really one behaviour, you have spent your episodes teaching the model that the words do not matter.
Of the five policies on this platform, ACT is the only one with no language pathway. Its implementation file in LeRobot (src/lerobot/policies/act/modeling_act.py) contains no tokenizer, no task key and no language embedding anywhere in the model. The dataset schema still carries a string: every frame handed to the dataset writer has to include a task key, and lerobot-record always supplies one. It does not have to be a useful one. DatasetRecordConfig.single_task defaults to an empty string in src/lerobot/configs/dataset.py, so a recording made without --dataset.single_task ships with an empty instruction and nothing complains. Everything in this article about phrasing applies to GR00T, Pi0.5 and SmolVLA. On ACT the instruction is a filing label.
Where the string lives between your keyboard and the GPU
The instruction is written once and then referenced by number. The dataset layout keeps every unique string in one small metadata table with an integer task_index, and each frame in the parquet file carries only that integer. Which file that table is depends on the dataset version: LeRobot v3.0 writes meta/tasks.parquet, while the v2.1 layout that GR00T reads writes meta/tasks.jsonl. Both hold the same two columns. This indirection is why renaming a task after the fact is a metadata edit rather than a rewrite of millions of rows, and also why a mismatched index maps silently onto the wrong sentence.
| Stage | Where the instruction lives | Who writes it |
|---|---|---|
| Recording | A `task` value attached to every frame | `lerobot-record --dataset.single_task="..."` |
| Dataset on disk (LeRobot v3.0) | `meta/tasks.parquet`, one row per unique string, plus a `task_index` column per frame | The LeRobot dataset writer |
| Dataset on disk (v2.1, what GR00T reads) | `meta/tasks.jsonl`, the same two columns as JSON lines | `convert_v3_to_v2.py`, which logs "Converting tasks parquet to legacy JSONL" |
| GR00T flavour of the same dataset | An extra parquet column `annotation.human.task_description` holding the same index | You, or a conversion script |
| Training | Looked up by index, tokenized, embedded alongside the image tokens | The policy's preprocessing pipeline |
| Inference with LeRobot | `lerobot-rollout --task="..."`, or `/subtask | Whoever starts the run |
| Inference with openpi | `"prompt": "pick up the fork"` in the observation dict | Your client code |
| Inference with Isaac-GR00T | `observation["language"] = {"annotation.human.task_description": obs["lang"]}`, with batch and time dims added | Your client code |
// meta/tasks.jsonl - the v2.1 layout, one JSON object per line.
// LeRobot v3.0 stores the same two columns in meta/tasks.parquet.
{"task_index": 0, "task": "cube into yellow bowl"}
{"task_index": 1, "task": "cube into green bowl"}
// one row of data/chunk-000/*.parquet, GR00T flavour
{
"observation.state": [-0.01, "...", 0],
"action": [-0.010, "...", 0],
"timestamp": 0.049,
"annotation.human.task_description": 0, // int index into the task table
"task_index": 0,
"episode_index": 0,
"index": 0
}Two consequences follow from this indirection. First, an episode recorded with a typo keeps that typo as a separate vocabulary entry forever, so "pick up the red cube" and "pick up the red cube " with a trailing space are two different tasks as far as the loader is concerned. Second, if you merge two datasets that were recorded on different days with slightly different wording, you have quietly built a two-task dataset out of one behaviour.

What each trainable policy does with your words
The five policies on the policy comparison page handle language in three different ways. GR00T reads a named annotation channel. Pi0.5 and SmolVLA read a task string through a tokenizer processor with an explicit token budget. ACT reads nothing. The token budgets below come from the policy configuration files in the LeRobot repository, read on 2026-08-24.
| Policy | How the instruction reaches the model | Token budget | Set in |
|---|---|---|---|
| GR00T N1.7 | Annotation channel `annotation.human.task_description`, resolved from `tasks.jsonl` | No cap in the fine-tune config; the tokenizer that ships with the base checkpoint decides | `gr00t/configs/finetune_config.py` has no tokenizer field; the processor is loaded from `base_model_path` |
| GR00T N1.5 | Same annotation channel, same loader | Same | Same |
| Pi0.5 | `prompt` folded into a `Task: ..., State: ...;` prefix together with the discretized state | 200 tokens | `configuration_pi05.py`, `tokenizer_max_length = 200` |
| SmolVLA | `task`, newline-terminated, tokenized by the SmolVLM2 tokenizer | 48 tokens | `configuration_smolvla.py`, `tokenizer_max_length = 48` |
| ACT | Not at all | None | No language code in `modeling_act.py` |
The original Pi0 uses 48 tokens as well; only Pi0.5 raised it to 200, and it needs the headroom because the discretized robot state is packed into the same prompt string as text. That design detail is easy to miss and it changes how much room your actual sentence has. If you are choosing between models on other grounds, the GR00T N1.7 against Pi0.5 comparison covers the rest of the trade-off.
LeRobot's TokenizerProcessorStep is constructed with truncation=True and calls the Hugging Face tokenizer with max_length. Nothing is logged when your instruction is longer than the budget. A 60-token SmolVLA prompt is cut to 48 in silence, at training time and at inference time, and the two cuts land in the same place only if the strings were identical. openpi is friendlier: its PaliGemma tokenizer logs Token length (N) exceeds max length (M), truncating. If your instruction reads like a paragraph, assume the tail is gone and rewrite it as a sentence. See policy only works in one setup for the related failure pattern.

The exact prompt string each model builds
Knowing that the string is tokenized is not enough, because none of these stacks tokenizes the raw string you typed. Each one wraps, cleans or reformats it first, and the wrapper is part of what the model learned.
GR00T: the annotation key has to match in three places
GR00T is the strictest of the three about naming. The same annotation is referenced from the parquet column, from meta/modality.json, and from the Python modality config, and the three spellings differ from each other by design. Datasets published by different groups use different keys, which is a common reason a GR00T fine-tune refuses to start.
| Layer | Where it lives | SO-100 / cube_to_bowl | LIBERO / SimplerEnv |
|---|---|---|---|
| Parquet column | `data/chunk-*/*.parquet` | `annotation.human.task_description` | `annotation.human.action.task_description` |
| modality.json key | under `"annotation"`, without the `annotation.` prefix | `human.task_description` | `human.action.task_description` |
| modality_keys | the `"language"` ModalityConfig in your data config | `annotation.human.task_description` | `annotation.human.action.task_description` |
# examples/SO100/so100_config.py - the language block of the SO-100 modality config
so100_config = {
"video": ModalityConfig(delta_indices=[0], modality_keys=["front", "wrist"]),
"state": ModalityConfig(delta_indices=[0], modality_keys=["single_arm", "gripper"]),
"action": ModalityConfig(delta_indices=list(range(0, 16)), modality_keys=[...]),
# Language: task instruction from annotation field in the dataset
"language": ModalityConfig(
delta_indices=[0],
modality_keys=["annotation.human.task_description"],
),
}
register_modality_config(so100_config, embodiment_tag=EmbodimentTag.NEW_EMBODIMENT)The episode loader asserts that the key you name exists under annotation in modality.json. If it does not, you get an assertion failure before the first training step, which is the good outcome. There is a second path: the loader also accepts the bare keys task and sub_task, which it reads from episodes.jsonl instead of the parquet column. Worth knowing before you write a conversion script for nothing.
In FinetuneConfig, tune_llm defaults to False and tune_visual defaults to False; only tune_projector and tune_diffusion_model default to True. Your fine-tune does not teach the model new language. It teaches a projector and a diffusion action head to use language understanding that is already frozen in place. That is a strong argument for phrasing your instruction in ordinary English rather than in internal jargon: the frozen encoder has seen "put the red cube in the bowl" and has not seen "exec pick_seq_02".
Pi0.5: your sentence plus 256 bins of robot state
openpi builds one long prefix out of the instruction and the proprioceptive state. The state is digitized into 256 bins and written out as integers, in text, inside the same prompt. Underscores become spaces and newlines are flattened before anything is tokenized, which is why an underscore-joined LIBERO environment name works as an instruction without any manual cleanup.
# openpi/src/openpi/models/tokenizer.py - PaligemmaTokenizer.tokenize
cleaned_text = prompt.strip().replace("_", " ").replace("\n", " ")
if state is not None:
# Pi0.5 format: the state is part of the discrete language input
discretized_state = np.digitize(state, bins=np.linspace(-1, 1, 256 + 1)[:-1]) - 1
state_str = " ".join(map(str, discretized_state))
full_prompt = f"Task: {cleaned_text}, State: {state_str};\nAction: "
tokens = self._tokenizer.encode(full_prompt, add_bos=True)
else:
# Pi0 format: the state goes to the continuous action expert instead
tokens = self._tokenizer.encode(cleaned_text, add_bos=True) + self._tokenizer.encode("\n")SmolVLA: a trailing newline you did not type
LeRobot inserts a processor step before the tokenizer whose only job is to append a newline to your task string if it does not already end with one. The registry name is still smolvla_new_line_processor for backward compatibility with serialized processor configs. LeRobot's own docstring says the step exists because certain tokenizers, PaliGemma among them, expect a newline at the end of the prompt; openpi calls the same character the start-of-answer token. It applies to both a single string and a list of strings.
# lerobot/src/lerobot/policies/smolvla/processor_smolvla.py
input_steps = [
steps.rename_observations,
steps.add_batch_dim,
NewLineTaskProcessorStep(), # appends "\n" if the task lacks one
TokenizerProcessorStep(
tokenizer_name=config.vlm_model_name, # HuggingFaceTB/SmolVLM2-500M-Video-Instruct
padding=config.pad_language_to, # "longest"
padding_side="right",
max_length=config.tokenizer_max_length, # 48
),
steps.to_device,
steps.normalize,
]How phrasing changes what the arm does
The clearest public illustration of this is the LIBERO-Spatial suite. LIBERO ships four task suites, and Spatial is ten tasks that share a verb, an object class and a target. The only thing that varies is a prepositional phrase saying which black bowl.
- pick up the black bowl between the plate and the ramekin and place it on the plate
- pick up the black bowl next to the ramekin and place it on the plate
- pick up the black bowl from table center and place it on the plate
- pick up the black bowl on the cookie box and place it on the plate
- pick up the black bowl in the top drawer of the wooden cabinet and place it on the plate
- pick up the black bowl on the stove and place it on the plate
- pick up the black bowl next to the plate and place it on the plate
Seven of the ten, verbatim from the task list at the end of the Isaac-GR00T LIBERO example (the underscores are environment names; the instruction is the same string with spaces). A policy that ignores the words scores at chance on this suite because the images look nearly identical. A policy that reads them has to ground "between", "next to" and "on" against the scene. NVIDIA reports 195 of 200 successful rollouts on LIBERO-Spatial for GR00T N1.7, fine-tuned at 20,000 max steps with a global batch size of 640 across 8 GPUs. That is a multi-GPU run, not anything you would launch for a single SO-100.
RT-2 made the format explicit. Its robot data was converted into a standard visual-question-answering string, and every demonstration in the underlying RT-1 dataset was annotated with a verb plus one or more object nouns from a small fixed skill set. That structure, verb first and object second, is still the shape that every VLA in this family was trained on. Our RT-2 walkthrough covers the web-knowledge transfer side of the paper in more depth.
# RT-2 (arXiv 2307.15818): robot data reformatted as visual question answering
Q: what action should the robot take to [task instruction]? A:
# a possible answer, one integer per action dimension
1 128 91 241 5 101 127
# the chain-of-thought variant adds a plan step before the action tokens
Instruction: I'm hungry. Plan: pick rxbar chocolate. Action: 1 128 124 136 121 158 111 255.Physical Intelligence put a limit on this in the Pi0.5 paper. Their model does hierarchical inference: it predicts a high-level subtask such as "pick up the plate" and then predicts actions conditioned on that subtask. Removing the verbal instruction data in an ablation, roughly 11 percent of the high-level mobile manipulation examples, degraded performance significantly. The limitation they state themselves is the one to remember: the prompts the model copes with are simple ones, and how complex a prompt it can take is set by the training data. You do not get instruction-following you did not record.
Rules that survive contact with a real dataset
The SmolVLA authors ran into the practical version of this at scale. Building their pretraining mixture from 481 community datasets covering 22.9 thousand episodes, they found task annotations that were placeholders like "task desc", one-word commands like "Hold" or "Up", or simply absent. Their fix was to re-annotate with Qwen2.5-VL-3B-Instruct, prompted to produce a short action-oriented sentence starting with a verb and capped at 30 characters. That cap is a useful anchor for how short a good instruction is.
- 1Write the instruction before you record, not after
Decide the exact string, put it in a shell variable, and never retype it. Every retype is a chance to introduce a trailing space or a capital letter that forks your task vocabulary. Start with a verb and name the object and the target explicitly.
bashTASK="put the red cube in the white bowl" lerobot-record \ --robot.type=so100_follower \ --robot.port=/dev/ttyACM0 \ --robot.id=my_follower \ --teleop.type=so100_leader \ --teleop.port=/dev/ttyACM1 \ --teleop.id=my_leader \ --dataset.repo_id=$HF_USER/red-cube-white-bowl \ --dataset.num_episodes=50 \ --dataset.single_task="$TASK" - 2Keep it under the shortest budget you might ever use
48 tokens is the SmolVLA and Pi0 limit. If there is any chance you will train the same dataset on SmolVLA later, write for 48 tokens now. A sentence of roughly ten to fifteen plain words is comfortably inside every budget on this platform.
textgood: put the red cube in the white bowl good: open the top drawer and put the bowl inside bad: Task 3b - operator should grasp the red cube from the mat, lift it clear of the fixture, translate to the bowl and release (see SOP rev 2) - 3Use words the frozen backbone already knows
GR00T freezes the language model during fine-tuning by default (
tune_llmis False), and SmolVLA's default recipe setstrain_expert_onlyto True andfreeze_vision_encoderto True, so it trains the action expert rather than the full VLM. Neither is going to learn your internal vocabulary from 50 episodes. Say "red cube", not "part A"; say "bowl", not "target receptacle". - 4Verify what actually landed in the dataset
Read the task table back before you spend money on a GPU. It takes three seconds and it catches every typo, every stray space and every accidental second task. LeRobot v3.0 writes it as parquet, so cat will not help you here.
bashDS=~/.cache/huggingface/lerobot/$HF_USER/red-cube-white-bowl # LeRobot v3.0: meta/tasks.parquet, indexed by the task string python -c "import pandas as pd; print(pd.read_parquet('$DS/meta/tasks.parquet'))" # after converting down to v2.1 for GR00T it is JSON lines again cat $DS/meta/tasks.jsonl # {"task_index": 0, "task": "put the red cube in the white bowl"} # more than one row here means your dataset is multi-task, # whether or not you meant it to be - 5Pin the same string on the inference side
Put the instruction in one file that both the recording script and the rollout script read. This is the single change that prevents most instruction drift, and it costs nothing.
bashecho "put the red cube in the white bowl" > task.txt lerobot-rollout \ --strategy.type=base \ --policy.path=outputs/train/checkpoints/last/pretrained_model \ --robot.type=so100_follower \ --robot.port=/dev/ttyACM0 \ --task="$(cat task.txt)" \ --duration=60
- The whole dataset trains one behaviour, so every episode reinforces the same conditioning instead of splitting it.
- Nothing to get wrong at inference: there is exactly one correct string, and you can diff it against the one row in the dataset's task table.
- Meets the platform minimum sooner. SmolVLA needs 30 episodes, the other four need 50, and those are per-behaviour numbers in practice.
- Language is not what stops you reusing the recording. The same dataset trains on all five policies once the format matches, which for GR00T means converting down to v2.1 first.
- You get no language generalisation. The policy has one command and will do roughly that behaviour whatever you type.
- Two related behaviours mean two datasets and two runs, which doubles both recording time and GPU cost.
- You lose the thing VLAs are actually for. If the goal is a robot that responds to different instructions, single-task datasets are a dead end you have to back out of later.
- Merging single-task datasets afterwards works, but only if you were disciplined about wording from the very first episode.
How training and inference drift apart
Drift is not exotic. It is the default outcome of a stack where the training string and the inference string are typed in two different terminals on two different days. Here are the four mechanisms worth knowing, all verified against upstream source on 2026-08-24.
| Drift mode | What actually happens | Symptom on the arm | Fix |
|---|---|---|---|
| Empty inference prompt | `src/lerobot/rollout/configs.py` declares `task: str = ""`. The newline step turns that into a bare newline and the tokenizer accepts it, because it only rejects `None` | Policy moves, but towards an average of everything it saw. Often reads as hesitancy | Always pass `--task`, or read it from a file both scripts share |
| prompt_from_task left off | openpi's `DataConfig.prompt_from_task` defaults to `False`. Its own LIBERO config sets it to `True` and calls that the recommended setting | Training loss falls normally, the policy has learned nothing from language | Set `prompt_from_task=True` in your fine-tune config |
| Wording changed between record and run | Two different token sequences. The frozen encoder maps them near each other, but not to the same point | Works for the person who recorded it, degrades for anyone else | One string, one file, both scripts |
| Silent truncation on one side only | A long training string is cut at 48 tokens; a shorter inference string is not cut. The two prompts now genuinely differ | Inconsistent success across otherwise identical setups | Keep every instruction well under the shortest budget you use |
Hugging Face says the quiet part out loud in its own SmolVLA guide. The evaluation command on that page carries an inline comment next to the --task flag telling you to use the same task description you used in your dataset recording. That comment exists because people do not, and the resulting failure looks like a bad checkpoint rather than a typo. The same page also notes that the SmolVLA reference dataset was 50 episodes across 5 cube positions, and that a 25-episode attempt performed badly.
In src/lerobot/rollout/configs.py the rollout config declares task: str = "". There is a convenience rule that copies --dataset.single_task into --task and back when only one of the two is set, and it logs a line when it does. Pass neither and nothing fires: a base rollout logs the strategy, the robot, the FPS and the duration, and never prints the task at all. Your GR00T or SmolVLA policy then runs the whole rollout on a blank prompt and you spend the afternoon blaming the checkpoint. Interactive mode is the one place the empty case is visible: /subtask with no argument prints the current task and names the empty one explicitly. Check the string yourself before you touch anything else, and see loss falls but the policy does nothing if the behaviour looks like a training problem instead.
LeRobot's interactive rollout mode makes the coupling visible in a way that is worth trying once. Start a rollout with --interactive=true and the robot stays idle until you type /start. From there /subtask
Two ways to get an aligned instruction into a trained policy
Everything above runs on your own machine with LeRobot and either Isaac-GR00T or openpi installed. The instruction handling costs you nothing extra; the work is the discipline of keeping one string in one place across two tools, plus a conversion step if you are going to GR00T.
# 1. record with one fixed instruction
TASK="put the red cube in the white bowl"
lerobot-record --robot.type=so100_follower --robot.port=/dev/ttyACM0 \
--teleop.type=so100_leader --teleop.port=/dev/ttyACM1 \
--dataset.repo_id=$HF_USER/red-cube --dataset.num_episodes=50 \
--dataset.single_task="$TASK"
# 2. confirm the vocabulary is exactly one row
python -c "import pandas as pd; print(pd.read_parquet('$HOME/.cache/huggingface/lerobot/$HF_USER/red-cube/meta/tasks.parquet'))"
# 3a. SmolVLA reads the task column directly, nothing to convert
lerobot-train --policy.path=lerobot/smolvla_base \
--dataset.repo_id=$HF_USER/red-cube --batch_size=64 --steps=20000 \
--output_dir=outputs/train/my_smolvla --policy.device=cuda
# 3b. GR00T needs LeRobot v2.1 plus a meta/modality.json with an annotation block
python scripts/lerobot_conversion/convert_v3_to_v2.py \
--repo-id $HF_USER/red-cube \
--root ~/.cache/huggingface/lerobot/$HF_USER/red-cube
# 4. run it back with the identical string
lerobot-rollout --strategy.type=base --policy.path=<checkpoint> \
--robot.type=so100_follower --robot.port=/dev/ttyACM0 \
--task="$TASK" --duration=60GR00T N1.7 loads LeRobot v2 datasets. A v3.0 dataset has to be converted down first, and the conversion is also where you add meta/modality.json with the annotation block. Get the key right in all three layers or the episode loader raises an assertion (Key ... not found in language modality) before the first training step. See dataset rejected as v3 for the version side of this.
- Feetech STS3215 servos on the SO-100 and SO-101 run at 7.4 V. Feeding them 12 V destroys them, and no instruction string will fix that.
- You are responsible for the GPU: an A100 or H100 80 GB for GR00T and Pi0.5, a 24 GB card for SmolVLA and ACT.
- GR00T's fine-tune entry point exposes no seed, so two identical runs are not bit-for-bit identical. LeRobot's default seed is 1000.
The platform does not write your instruction for you and does not check your phrasing. What it removes is the part of the loop where instructions usually get lost: the recording tool, the trainer and the inference pod all read the same dataset, so the string that reaches the GPU is the string that is in meta/tasks.parquet. The desktop client records LeRobot-format datasets straight from a teleop session, the training form picks model, dataset and hyperparameters and rents a GPU by required VRAM, and inference runs on an auto-provisioned pod that the local robot client talks to.
| Policy | GPU tier | Typical run | Cost per run | Minimum episodes |
|---|---|---|---|---|
| GR00T N1.7 | A100 80 GB or H100 80 GB | 3 to 6 hours | about 4 to 12 USD | 50 |
| GR00T N1.5 | A100 80 GB or H100 80 GB | 3 to 6 hours | about 4 to 12 USD | 50 |
| Pi0.5 | A100 80 GB or H100 80 GB | 3 to 6 hours | about 4 to 12 USD | 50 |
| SmolVLA | RTX 4090 or any 24 GB card | 2 to 5 hours | about 1 to 3 USD | 30 |
| ACT | RTX 4090 or any 24 GB card | 2 to 5 hours | about 1 to 3 USD | 50 |
- Datasets can come from the public dataset directory, from a Hugging Face repo id, or from your own machine, and the task string travels with them either way.
- GR00T and Pi0.5 are cloud-only here. SmolVLA and ACT also run locally.
- Inference pods carry an idle watchdog and destroy themselves after an idle period, so a forgotten prompt experiment does not keep billing.
- The same operations are available from the CLI and the MCP server, which is the practical way to script a sweep over several phrasings.
There is no instruction linter. Nothing warns you that your sentence is 60 tokens long, that your dataset accidentally contains two task strings, or that the phrase you typed at inference time is not the phrase in the dataset. Those are your checks, and the commands earlier in this article are how you run them. Pick a model first on the policies page if you have not, because the token budget you have to write inside depends on it.
Multi-task datasets, and when they are worth it
Everything above pushes towards one instruction per dataset, which is the right default for a first training run. It is also the choice that gives up the entire point of a language-conditioned model. The decision is really about how many episodes you are prepared to record.
| Goal | Instructions in the dataset | Episodes needed | Realistic outcome |
|---|---|---|---|
| One repeatable behaviour | One string | 30 for SmolVLA, 50 for the rest | Reliable at the recorded behaviour, no language response |
| Two objects, same motion | Two strings differing only in the noun | Roughly the minimum per string, so double | The policy can be steered between them if the objects look different |
| Same object, two destinations | Two strings differing only in the target phrase | Double, and balanced | Hardest of the three. The images are nearly identical, so the words carry all the signal |
| Long-horizon sequence | One high-level string, or subtask annotations | Substantially more, and this is where LIBERO-Long sits | Out of reach for a small SO-100 dataset. Split it into stages instead |
If you do go multi-task, balance matters. Forty episodes of "put the cube in the red bowl" and ten of "put the cube in the blue bowl" produce a policy that goes to the red bowl and occasionally hesitates. LeRobot also ships lerobot-annotate, which fills language_persistent and language_events columns on an existing dataset with a vision-language model, and can push the work to a Hugging Face Jobs GPU instead of your laptop. It writes new language columns rather than rewriting the task table, so it is a relative of the re-annotation the SmolVLA authors ran over 481 community datasets, not the same operation. Our guide on collecting high-quality VLA training data covers the recording side of a balanced multi-task set.
# lerobot-annotate: fill language columns on an existing dataset with a VLM
uv run lerobot-annotate \
--root=/path/to/dataset \
--vlm.model_id=Qwen/Qwen2.5-VL-7B-Instruct
# or push the work to a Hugging Face Jobs GPU instead of your machine
uv run lerobot-annotate \
--repo_id=user/dataset \
--new_repo_id=user/dataset_annotated \
--push_to_hub=true \
--job.target=h200When the words are not the problem
Instruction drift is a real failure mode and it is cheap to rule out, which is why it belongs near the top of a debugging list rather than in the middle. But it is not the biggest lever on most setups, and pretending otherwise wastes time.
Print the task string the rollout is using. Compare it byte for byte with the single row in the dataset's task table (meta/tasks.parquet, or meta/tasks.jsonl after the GR00T conversion). If they match and the policy still fails, the instruction is not your problem, and the usual suspects are camera placement, lighting, calibration drift, or simply too few episodes. The failure-mode index is organised by symptom for exactly this reason. On ACT you can skip the check entirely, since the string never reaches the network.
There is a second limit worth stating plainly. Language conditioning does not survive a slow control loop. Inference on this platform runs from 20 ms per action step for ACT to 485 ms for Pi0.5, and adding public-internet round trips on top of that turns a working policy into a hesitant one. Remote inference is viable for slow pick-and-place, not for fast reactive motion, and no amount of prompt engineering compensates. If you want the fastest loop, the ACT against SmolVLA comparison is the honest starting point, and the arena has 85 VLA models with 332 benchmark results if you want to see how the wider field measures instruction following.

One last framing that has held up across every stack examined here. The instruction is not an interface to the robot. It is an interface to the training data. What you can say to a policy is bounded by what somebody said while recording it, and the wider the range of phrasings in the dataset, the wider the range the policy will accept. That is a data collection decision made months before anyone types a prompt, and it is worth reading the VLA overview with that in mind.
Train a policy on your own instruction
Pick a model and an arm, and get the guide for that exact combination: the dataset format it needs, the defaults the trainer really sends, and the GPU tier it rents. Five policies, four arms.
Open the training matrixDoes the task instruction have to be identical at training and inference time?▾
It does not have to be, but any difference is a difference in the input tensor. The safest practice is byte-identical: keep one string in one file that both the recording command and the rollout command read. If you want to test whether the policy generalises across phrasings, do it deliberately with lerobot-rollout in interactive mode using /subtask, not by accident.
How long can a task instruction be?▾
SmolVLA and Pi0 tokenize to 48 tokens, Pi0.5 to 200, and GR00T sets no cap in its fine-tune config. LeRobot truncates silently at the configured maximum; openpi logs a warning first. Roughly ten to fifteen ordinary words fits every budget on this platform with room to spare. The SmolVLA authors capped their auto-generated annotations at 30 characters.
What happens if I do not pass an instruction at all?▾
It depends on the stack. openpi refuses: its TokenizePrompt transform in src/openpi/transforms.py raises ValueError('Prompt is required') when the key is missing. LeRobot's rollout config defaults --task to an empty string, which is not None, so the newline step turns it into a bare newline, the tokenizer accepts that, and the policy runs on a blank prompt with no error. That silent case is the one to watch for.
Does the instruction matter for ACT?▾
No. ACT has no language pathway at all: there is no tokenizer and no task key anywhere in its LeRobot implementation. The dataset still stores a task string, because every frame written to a LeRobot dataset must carry a task key, but --dataset.single_task itself is optional and defaults to an empty string, and the network never reads it either way. ACT also has no base model, so it only exists after you train it on your task.
Can one dataset teach a policy several instructions?▾
Yes, that is what the task_index column is for, and it is the whole reason to use a VLA rather than ACT. The practical constraint is episodes: the platform minimum is 30 for SmolVLA and 50 for the others, and those numbers behave like per-behaviour minimums. Two instructions means roughly double the recording, balanced between them.
Why does GR00T reject my dataset even though the task string is there?▾
Almost always a key mismatch rather than a missing string. GR00T resolves the annotation from three places that must agree: the parquet column name, the key under 'annotation' in meta/modality.json, and modality_keys in the data config. SO-100 style datasets use annotation.human.task_description; LIBERO and SimplerEnv datasets use annotation.human.action.task_description. The loader also accepts the bare keys task and sub_task, which it reads from episodes.jsonl instead of a parquet column. Separately, GR00T loads LeRobot v2, so a v3.0 dataset has to be converted down first.
Sources
- Isaac-GR00T data preparation: the annotation column naming table, meta/tasks.jsonl and the example parquet row
- Isaac-GR00T FinetuneConfig: tune_llm and tune_visual default False, tune_projector and tune_diffusion_model default True, state_dropout_prob 0.2
- Isaac-GR00T LIBERO example: 195/200 on Spatial at 20K steps and global batch 640, plus the full task list
- Isaac-GR00T SO-100 evaluation client: observation["language"] carries annotation.human.task_description
- LeRobot dataset paths: DEFAULT_TASKS_PATH meta/tasks.parquet and LEGACY_TASKS_PATH meta/tasks.jsonl
- LeRobot DatasetRecordConfig: single_task defaults to an empty string
- LeRobot RolloutConfig: task defaults to an empty string, the single_task propagation rule, the interactive commands
- LeRobot TokenizerProcessorStep: task_key, max_length, truncation True by default, subtask handling
- SmolVLA configuration: tokenizer_max_length 48, pad_language_to, vlm_model_name SmolVLM2-500M-Video-Instruct
- LeRobot documentation: fine-tuning SmolVLA, the 50 episode reference dataset, and the --task note to reuse the recording task description
- openpi PaligemmaTokenizer: prompt cleaning, the Task/State prefix, 256 state bins and the truncation warning
- openpi DataConfig.prompt_from_task defaults to False; the LIBERO configs set it True and call that the recommended setting
- RT-2: Vision-Language-Action Models Transfer Web Knowledge to Robotic Control (Brohan et al., 2023)
- SmolVLA: A Vision-Language-Action Model for Affordable and Efficient Robotics (Shukor et al., 2025)
- pi-0.5: a Vision-Language-Action Model with Open-World Generalization (Physical Intelligence, 2025)
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started