LeRobot has no offline mode. A grep for HF_HUB_OFFLINE across the v0.6.1 tree returns zero matches, and none of the 18 console scripts carries an offline or local-files-only flag. What you get instead is four flags, and they do not cover everything. DatasetRecordConfig sets push_to_hub to True with private left at None, so lerobot-record and lerobot-rollout upload to a public repo by default, from inside a finally block that a Ctrl-C still reaches. Three of the five rollout strategies queue a mid-run background push that never reads the push_to_hub flag at all, so --dataset.push_to_hub=false stops their teardown upload and not their mid-run one. On the read side, LeRobot keeps two caches and resolves the dataset revision by listing Hub refs before either is consulted, which is why a warmed cache does not survive the cable being pulled. Nineteen backbone repository ids sit as defaults in twelve policy configuration files and resolve independently of your checkpoint. Start by pointing every dataset at an explicit --dataset.root and re-running your capture command with the flag off.
Before you run LeRobot offline, read line 43 of src/lerobot/configs/dataset.py. At the v0.6.1 tag it says push_to_hub: bool = True, directly under a comment that reads # Upload dataset to Hugging Face hub. Two lines down sits private: bool | None = None, and the comment there explains that None defers to the organisation default, which only affects organisations. On a personal account there is no organisation default, so the repo is public. Every teleoperation session captured with lerobot-record uploads itself to a public Hugging Face dataset repository unless you pass one flag, and the upload runs from inside the finally: block of lerobot_record.py, so a Ctrl-C or a mid-session exception still reaches it.
Fixing that is one flag. The harder problem is that LeRobot has no offline mode to turn on. A recursive grep for HF_HUB_OFFLINE across the whole v0.6.1 tree returns zero matches, and none of the 18 console scripts declared in pyproject.toml carries an --offline or --local_files_only flag. Four flags are what you get: --dataset.root, --policy.path pointed at a directory, --dataset.push_to_hub=false and --policy.push_to_hub=false. They do not cover every path, and one of them is ignored in three places.
This is the inventory: which commands in the record-train-evaluate loop touch the Hub, what each one asks for, which flag stops it, and where the flag does not work. Everything below was read out of LeRobot v0.6.1, tagged 3 August 2026 and the current release, and huggingface_hub v1.31.0. The rest of the physical AI pillar covers what to do with the data once it is safely inside.
The eight commands, and what each one asks the Hub for
Eighteen console scripts ship in v0.6.1. Eight of them make up the loop a capture rig actually runs, and those are the ones with a Hub surface worth auditing. Of the eight, exactly two have none: lerobot-calibrate and lerobot-teleoperate contain no reference to LeRobotDataset, push_to_hub, snapshot_download, HfApi, from_pretrained or repo_id anywhere in their source.
Three rows deserve a note.
lerobot-eval has no push_to_hub toggle at all. Setting --eval.recording_repo_id is itself the upload switch, it requires --eval.recording=true or the config raises, and the companion field is recording_private: bool = False. So the eval recording path has the same public default as recording, with a different shape: there is no flag to turn the upload off, only a flag not to turn it on. The push sits in a finally: block too.
lerobot-edit-dataset is the only row in that table defaulting the other way, at push_to_hub: bool = False, and it is a top-level flag rather than a nested --dataset. one. The dataset operations it performs and what they rewrite are a separate subject, worked through in what a LeRobot dataset merge actually breaks.
lerobot-train needs one correction to a thing we have written before. Intermediate checkpoints really do stay put: TrainPipelineConfig.save_checkpoint_to_hub is False. But PreTrainedConfig.push_to_hub is True, and at the end of training lerobot_train.py pushes the final model, the preprocessor and the postprocessor, three artifacts, on that flag alone. The comment on save_checkpoint_to_hub says so in its own words: the final model is pushed regardless. Validation then refuses to start a local run that has push_to_hub on and no --policy.repo_id, raising a missing repo_id error, so the choice is forced rather than silent. The rest of the training loop inside a perimeter, including what the experiment tracker does, is in debugging a VLA fine-tune that rolls out at 0 percent.
| Command | What it asks the Hub for | Default | What stops it |
|---|---|---|---|
lerobot-calibrate | Nothing | No Hub surface | Nothing to set |
lerobot-teleoperate | Nothing | No Hub surface | Nothing to set |
lerobot-record | Uploads the recorded dataset at teardown | push_to_hub: bool = True, public | --dataset.push_to_hub=false |
lerobot-replay | Resolves the dataset by repo id, then reads it | Downloads when the tree is not on disk | --dataset.root=<tree> |
lerobot-rollout | Resolves the policy, uploads recorded episodes | Same DatasetRecordConfig, so True | --dataset.push_to_hub=false, with three exceptions |
lerobot-train | Resolves dataset and policy, pushes the final model | PreTrainedConfig.push_to_hub is True | --policy.push_to_hub=false |
lerobot-eval | Resolves the policy, uploads eval rollouts | No upload unless --eval.recording_repo_id is set, then public | Leave --eval.recording_repo_id unset |
lerobot-edit-dataset | Resolves the dataset, uploads on request | push_to_hub: bool = False | Already off |
The kill switch three of five rollout strategies never read
lerobot-rollout takes --strategy.type with five values: base, sentry, highlight, dagger and episodic. Four of them record. All four route their uploads through the same safe_push_to_hub helper. They do not all check the same thing before calling it.
The mechanism is the same in all three unguarded cases. Each strategy builds a single-worker ThreadPoolExecutor in setup() unconditionally, and its _background_push method submits a task that calls safe_push_to_hub(dataset, ...) with no reference to cfg.dataset.push_to_hub. In sentry.py, the call site is a counter check on upload_every_n_episodes and nothing else. The teardown path in the same file does read the flag, and that is what makes the gap easy to miss: with the flag off, the run ends without a final upload, so the flag looks like it worked.
Two details make it quieter still. The background push catches every exception and logs Background push failed: %s rather than raising, so on a host that blocks egress the run continues and the failure is a log line rather than a failed run. And safe_push_to_hub returns False without pushing when no episodes have been saved, so a short smoke test can pass while a real session uploads.
Until this is closed upstream, treat the rollout strategies as a network decision rather than a flag decision. If a policy is running on a robot holding data that cannot leave, run --strategy.type=episodic, or run base and record nothing, or put the host somewhere the push cannot succeed and accept the log noise. --strategy.type=sentry --strategy.upload_every_n_episodes=5 is the invocation the script's own header documents, and it is the one to keep off a sealed rig.
| Strategy | Records | Mid-run upload | Teardown upload gated on push_to_hub | Mid-run upload gated on push_to_hub |
|---|---|---|---|---|
base | No | None | No upload path | No upload path |
sentry | Continuous | Every upload_every_n_episodes, default 5 | Yes | No, it fires regardless |
highlight | Ring buffer | On the push key, default h | Yes | No, it fires regardless |
dagger | Corrections, optionally autonomous | Every 5 episodes, or on the upload key, default enter | Yes | No, it fires regardless |
episodic | Episode by episode | None | Yes | No mid-run path |
Two caches, and the one a cold offline start never reaches
LeRobot keeps two roots under HF_LEROBOT_HOME, which defaults to $HF_HOME/lerobot. Getting them backwards is how a dataset that is demonstrably on disk stays invisible to the trainer.
The first is the plain tree at HF_LEROBOT_HOME / repo_id, which the docs describe as ~/.cache/huggingface/lerobot/{repo-id}. When you construct a dataset with no --dataset.root, that is where LeRobotDatasetMetadata sets self.root and where it tries to load metadata from first. The second is HF_LEROBOT_HUB_CACHE, which is $HF_LEROBOT_HOME/hub, and it is only ever written by a download. The comment on the constant is explicit that it is not the system-wide ~/.cache/huggingface/hub/, because LeRobot passes it as cache_dir to snapshot_download so that different dataset revisions land in isolated snapshot directories.
The ordering is what matters. The metadata loader tries the plain tree, and only on FileNotFoundError does it run the version lookup and then the download into the hub cache. That puts the hub cache behind a live API call: snapshot_download is the only thing that reads $HF_LEROBOT_HOME/hub, and with the default revision it runs after get_safe_version, never before it. Warm that cache, cut the network, and a cold start still dies on the lookup standing in front of it. Stage the dataset as a tree, at an explicit root or at $HF_LEROBOT_HOME/<repo_id>, and the Hub is never touched.
There is a marker file that turns a correctly staged tree back into a download. hf download --local-dir writes bookkeeping under <local-dir>/.cache/huggingface/download/, one .metadata file per downloaded file. LeRobot's has_legacy_hub_download_metadata tests for exactly that directory and treats it as proof the tree came from the old non-revision-safe mode, raising FileNotFoundError to force a re-fetch. The check is guarded by self._requested_root is None, so it only bites when the dataset is later opened without --dataset.root, which is exactly what the documented command does.
# Stage the tree from a host that still has egress. Nothing here depends on # HF_LEROBOT_HOME: --local-dir writes exactly where you point it. hf download lerobot/svla_so101_pickplace \ --repo-type dataset \ --revision v3.0 \ --local-dir /srv/robot-data/svla_so101_pickplace # --local-dir leaves <local-dir>/.cache/huggingface/download/ behind, which # LeRobot reads as "fetched the old way, fetch it again" on any later open # that does not pass --dataset.root. rm -rf /srv/robot-data/svla_so101_pickplace/.cache/huggingface # Then pass the tree explicitly, every time: # --dataset.root=/srv/robot-data/svla_so101_pickplace
Whether removing that directory breaks a later resumed or incremental download into the same path is untested here, and the marker files are how huggingface_hub tracks what it already holds, so the safe habit is to keep the pristine download on the staging host and copy the tree across rather than mutating it in place.
Two adjacent behaviours are worth knowing without re-deriving them. hf download refuses --local-dir and --cache-dir together, so you pick a layout rather than getting both. And force_cache_sync is the first branch in that same metadata loader, raising FileNotFoundError deliberately to skip the local read; what that does to an old dataset conversion is covered in why the v2.0 conversion path was removed.
The version lookup runs before the cache
HF_HUB_OFFLINE governs huggingface_hub and not your firewall, and we worked through what it does and does not switch off in air-gapped and offline vLLM deployment. The LeRobot-specific part is narrower and more useful: the variable never appears in the LeRobot tree, and the call that breaks under it is not a download.
When the local metadata read fails, LeRobotDatasetMetadata checks is_valid_version(self.revision) and, if it passes, calls get_safe_version(self.repo_id, self.revision). The default revision is CODEBASE_VERSION = "v3.0", a valid PEP 440 string, so the gate passes. get_safe_version calls get_repo_versions, which builds an HfApi() and calls list_repo_refs(repo_id, repo_type="dataset") to enumerate branches and tags. That is a live API request with no cached counterpart, and under offline mode huggingface_hub's request hook raises OfflineModeIsEnabled with the message Cannot reach {url}: offline mode is enabled. To disable it, please unset the HF_HUB_OFFLINE environment variable. Nothing in LeRobot catches it.
Compare that with snapshot_download, which catches OfflineModeIsEnabled alongside connection and timeout errors, keeps the exception, and falls through to a cache lookup. When the cache misses it raises LocalEntryNotFoundError with Cannot find an appropriate cached snapshot folder for the specified revision on the local disk and outgoing traffic has been disabled. Downloads degrade to cache. Raw API calls do not.
That gives you a diagnostic rather than a fix. OfflineModeIsEnabled naming a refs URL means a version lookup, and no amount of cache warming will help; stage the tree and pass --dataset.root. LocalEntryNotFoundError means the lookup was skipped or succeeded and the cache genuinely does not hold that revision, which is a staging error you can correct.
One more thing falls out of the same code, and it is a reading rather than a supported switch: because the version lookup is gated on is_valid_version, a revision string that is not PEP 440 skips it. --dataset.revision=main parses as an invalid version, so the lookup is bypassed and control passes straight to snapshot_download, which can serve from $HF_LEROBOT_HUB_CACHE under offline mode. That reading covers the two call sites we opened, not the whole tree. If you want to rely on it, populate the cache with hf download --cache-dir "$HF_LEROBOT_HOME/hub", which is the same function and the same cache_dir LeRobot uses, and prove it on your own dataset with the network already cut.
Nineteen backbone repos the policy code fetches for itself
A policy directory that satisfies --policy.path is not the whole model. PreTrainedPolicy.from_pretrained branches on os.path.isdir(model_id), prints Loading weights from local directory, and loads model.safetensors from that directory with no Hub call; the config load above it does the same with config.json. The lerobot-eval docstring states the requirement directly: the repo or folder should contain at least config.json and model.safetensors. Loading weights from local directory is the line to assert on in a staging check, because it distinguishes a local load from a cached remote one.
What the directory does not contain is the backbone. Nineteen distinct repository ids sit as defaults across twelve policy configuration files at v0.6.1, in twenty-one separate fields, and they resolve through transformers against the ordinary huggingface_hub cache rather than HF_LEROBOT_HUB_CACHE.
Two of those loads execute third-party code. PI0FastPolicy.__init__ calls AutoProcessor.from_pretrained(config.action_tokenizer_name, trust_remote_code=True) and AutoTokenizer.from_pretrained(config.text_tokenizer_name, trust_remote_code=True, ...), so choosing pi0-fast means accepting remote code execution from two repositories at policy construction time, before any training step runs. A private mirror is the answer on a regulated rig, because it pins what executes to something a change-control process can review.
Whether google/paligemma-3b-pt-224 can be mirrored at all is a question to settle before you plan around pi0-fast. Attempt the download from a staging host that has never accepted a licence for it, and see whether a gated-repo error comes back. Do that before you book the GPU, not after.
EnvHub is the other remote-code surface, and it is explicit about it. make_env on a Hub reference raises unless trust_remote_code=True: Refusing to execute remote code from the Hub for '{cfg_str}'. Executing hub env modules runs arbitrary Python code from third-party repositories. The error itself recommends pinning, and the URI parser accepts user/repo@<revision>[:path] with the path defaulting to env.py.
from lerobot.envs import make_env
# Pinned to a commit rather than a moving branch. Without trust_remote_code=True
# this raises "Refusing to execute remote code from the Hub".
env = make_env("trusted-org/verified-env@a1b2c3d4", trust_remote_code=True)make_env takes hub_cache_dir: str | None = None and LeRobot never passes it, so a pre-staged env module has to live in the default huggingface_hub cache, not in $HF_LEROBOT_HOME/hub. That is the opposite of the dataset rule, and it belongs in the staging script rather than in somebody's memory.
| Policy family | Config field | Default repository |
|---|---|---|
| SmolVLA | vlm_model_name | HuggingFaceTB/SmolVLM2-500M-Video-Instruct |
| pi0-fast | text_tokenizer_name, action_tokenizer_name | google/paligemma-3b-pt-224, lerobot/fast-action-tokenizer |
| GR00T | GROOT_N1_7_BASE_MODEL, GROOT_N1_7_BACKBONE_MODEL | nvidia/GR00T-N1.7-3B, nvidia/Cosmos-Reason2-2B |
| EO-1 | vlm_base | Qwen/Qwen2.5-VL-3B-Instruct |
| EVO-1 | vlm_model_name | OpenGVLab/InternVL3-1B-hf |
| V-JEPA | qwen_model_name, jepa_encoder_name | Qwen/Qwen3-VL-2B-Instruct, facebook/vjepa2-vitl-fpc64-256 |
| MolmoAct2 | checkpoint_path, discrete_action_tokenizer | allenai/MolmoAct2, allenai/MolmoAct2-FAST-Tokenizer |
| xVLA | tokenizer_name | facebook/bart-large |
| WALL-X | pretrained_name_or_path, action_tokenizer_path | x-square-robot/wall-oss-flow, lerobot/fast-action-tokenizer |
| FastWAM | four module constants | Wan-AI/Wan2.2-TI2V-5B, its -Diffusers variant, lerobot/fastwam_base, google/umt5-xxl |
| Lingbot-VA | wan_pretrained_path | robbyant/lingbot-va-base |
| MultiTaskDiT | vision_encoder_name, text_encoder_name | openai/clip-vit-base-patch16 |
What --job.target does to the flags you set
lerobot-train runs locally when --job.target is unset or set to local. Any other value is a Hugging Face Jobs hardware flavor, and the effect on a perimeter is not partial.
build_remote_config_file deep-copies your config and then overwrites it: remote.policy.push_to_hub = True and remote.dataset.root = None. A --policy.push_to_hub=false you typed on the command line is discarded, and the local dataset root you set to keep the data on disk is removed so the pod can resolve the dataset by repo id. Before the job is submitted, ensure_dataset_available checks repo_exists and, for a dataset that exists only in the local cache, pushes it to a private Hub repository. The module docstring states the reasoning plainly: the pod cannot see the host's ~/.cache/huggingface/lerobot, so the dataset has to live on the Hub.
Then _stage_config_on_hub calls create_repo(repo_id, repo_type="model", private=True, exist_ok=True) and uploads train_config.json into it. Two credentials go with the job as secrets: HF_TOKEN always, and when wandb is enabled, WANDB_API_KEY resolved from the environment or, failing that, read out of ~/.netrc for api.wandb.ai.
Robot demonstration data is process detail: fixture geometry, part handling, cycle time, line layout. For a manufacturing or defence-adjacent buyer that is the same category as CAD, and the argument for keeping it on owned hardware is the one we set out in cloud versus on-premise AI security and cost. The specific point here is narrower and easier to act on: --job.target is not a flag that can be made compatible with a perimeter by adding other flags, because the other flags are overwritten. It is either absent or the perimeter is not real. On a rig that captures regulated data, the cleanest control is a wrapper that rejects the argument before lerobot-train ever parses it.
The install is the other half of the perimeter
You cannot stage what you cannot install, and LeRobot's wheel choices are specific enough that a mirror built from assumptions will fail on the robot host.
requires-python is >=3.12, and the installation guide shipped in that tree asks for Python 3.12 or newer and support for PyTorch 2.10 or newer, while the dependency pin is torch>=2.7,<2.12.0. The uv source install pins torch and torchvision to the CUDA 12.8 index, https://download.pytorch.org/whl/cu128, with a documented driver floor of 570.86. The PyPI default wheel is a cu130-bundled Linux build with a driver floor of 580.65. Those are two different driver requirements for the same package depending on how you resolved it, so record which index your mirror copied from and pin the driver check to match.
Video decoding splits the same way. LeRobot uses TorchCodec by default, which requires ffmpeg. TorchCodec is unavailable on macOS Intel, Linux ARM and Windows with PyTorch below 2.8, and LeRobot falls back to pyav there with no ffmpeg needed. Where TorchCodec is available, system-wide ffmpeg is only supported with PyTorch 2.10 or newer, meaning TorchCodec 0.10 or newer; below that the documented requirement is conda install ffmpeg -c conda-forge. On an ARM robot host that ends up on the pyav path, an ffmpeg mirror is effort spent on a dependency that will not be loaded.
The acceptance test to run with the cable out
Prove the sealed host with commands, not with a reading of your config. The container and network-namespace harness for this is the vLLM post's and is not restaged here; what follows is the LeRobot-specific set of assertions to run inside it.
set -euo pipefail export HF_LEROBOT_HOME=/srv/lerobot export HF_HUB_OFFLINE=1 # 1. Must pass: lerobot-calibrate has no Hub surface at all. lerobot-calibrate \ --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=arm # 2. Must pass: replay reads an explicit local tree and never resolves a revision. lerobot-replay \ --robot.type=so101_follower --robot.port=/dev/ttyACM0 --robot.id=arm \ --dataset.repo_id=local/so101_test \ --dataset.root=/srv/robot-data/so101_test \ --dataset.episode=0 # 3. Nothing should have appeared in the revision-safe Hub cache. This is an # ordinary shell check, not a LeRobot feature; it is one way to assert it. if find "$HF_LEROBOT_HOME/hub" -mmin -10 -print 2>/dev/null | grep -q .; then echo "FAIL: something reached $HF_LEROBOT_HOME/hub"; exit 1 else echo "PASS: Hub cache untouched" fi
The USB device paths above and below are placeholders; the upstream examples use macOS-style /dev/tty.usbmodem* paths and yours will differ. Then capture, with the upload off and the tree explicit:
lerobot-record \
--robot.type=so101_follower \
--robot.port=/dev/ttyACM0 \
--robot.id=my_awesome_follower_arm \
--robot.cameras="{ front: {type: opencv, index_or_path: 0, width: 1920, height: 1080, fps: 30}}" \
--teleop.type=so101_leader \
--teleop.port=/dev/ttyACM1 \
--teleop.id=my_awesome_leader_arm \
--display_data=true \
--dataset.repo_id=local/record-test \
--dataset.num_episodes=5 \
--dataset.single_task="Grab the black cube" \
--dataset.root=/srv/robot-data/record-test \
--dataset.push_to_hub=falseAnd train with nothing pointed outward. --policy.path resolves to a directory, so the weights load locally; --dataset.root keeps the metadata read on the first branch; --policy.push_to_hub=false cancels the end-of-training push of the model and both processors. save_checkpoint_to_hub is left at its False default and is deliberately absent. SmolVLA's backbone is the separate prerequisite: HuggingFaceTB/SmolVLM2-500M-Video-Instruct has to be in the default huggingface_hub cache already, because the checkpoint directory does not carry it.
export HF_LEROBOT_HOME=/srv/lerobot export HF_HUB_OFFLINE=1 lerobot-train \ --dataset.repo_id=local/so101_test \ --dataset.root=/srv/robot-data/so101_test \ --policy.path=/srv/models/smolvla_base \ --policy.device=cuda \ --policy.push_to_hub=false \ --output_dir=/srv/runs/smolvla_so101_test \ --job_name=smolvla_so101_test \ --wandb.enable=false
Two things this test will not tell you. It does not measure how long a blocked list_repo_refs stalls before failing on a firewall that drops rather than rejects, and on a rig also servicing a 30 fps camera loop that latency is worth timing yourself. And it says nothing about HF Storage Bucket datasets: --dataset.repo_type=bucket routes through sync_bucket over hf://buckets/, the config refuses bucket without streaming=true, and no snapshot fallback is visible on that path. Read that as a reading of the code, not as documented behaviour, and keep buckets out of a sealed deployment until someone proves otherwise.
This week, do the smallest version. Take the capture command your team already pastes into a terminal, add --dataset.root and --dataset.push_to_hub=false, and record one episode with the network interface down. If it completes and $HF_LEROBOT_HOME/hub is untouched, you have the first row of your acceptance table and a command worth putting in a runbook. If a policy is also running on that rig, check --strategy.type before you trust the flag.
FAQ
Quick answers to the questions this post tends to raise.



