Data format
This page describes what a trial leaves on disk, and where TANDEM writes its logs. Paths are the Linux defaults (moving them). For the terms used here, see Concepts.
Reading an episode
import json
from pathlib import Path
import numpy as np
ep = Path("~/tandem-data/trajectories/my-task/success/2026-09-25_14-03-12").expanduser()
meta = json.loads((ep / "_meta.json").read_text())
state = np.load(ep / "robot_state.npz")
print(meta["instruction"], meta["n_frames"], "frames at", meta["fps"], "fps")
print(state["joint_position"].shape) # (F, 7)
# Who did each phase, and did its camera checks pass?
hitl = json.loads((ep / "hitl.json").read_text())
for phase in hitl["phases"]:
verdicts = [v["satisfied"] for v in hitl["verifications"] if v.get("phase") == phase["index"]]
print(phase["index"], phase["executor"], phase["description"], verdicts)
# The frame shown 4.2 s into the videos (merged episodes have video_time).
frame = int(np.searchsorted(state["video_time"], 4.2))
Episode layout
Each trial is one directory, under success/ or failure/:
~/tandem-data/trajectories/<profile>/<status>/<YYYY-MM-DD_HH-MM-SS>/
├── external_cam.mp4 external_cam_2.mp4 hand_cam.mp4 exterior 1, exterior 2 (optional), wrist
├── robot_state.npz per-frame arrays
├── _meta.json lineage, timing, and which leg and phase each frame belongs to
├── hitl.json the phase record
├── vlm/ model queries
├── tiptop_plan.json, … the primary leg's other files
└── segments/NN_<source>_<leg dir>/ the raw legs, in order
The videos and arrays join every leg of the trial in order. The episode takes the
primary leg's directory name, its _meta.json, and its other files except *.log. For TiPToP
those are tiptop_plan.json, metadata.json, rgb.png and perception/.
A trial with a single leg is not merged: the leg is the episode. The format is the same as hitl-tamp-vla's. To review and export episodes, see Reviewing and exporting.
robot_state.npz
The file has one row per frame. The per-leg arrays are listed in the recording contract. An episode may also have:
| array | shape | what it is |
|---|---|---|
video_time |
[F] float64 |
Merged episodes only: the frame's time in the joined videos, in seconds. |
action_joint_velocity |
[F,7] |
The DROID joint-velocity action, if any leg recorded it (TiPToP does). For legs without it, teleop and policy legs copy cmd_joint_velocity, and planner legs get 5 × (cmd_joint_position − joint_position). |
To match frames to video in a merged episode, use video_time. Don't use frame_time, which keeps the gaps
between legs. In a single leg, frame i sits (frame_time[i] − record_start) / (record_stop − record_start) of
the way through each video.
The arrays the export uses are listed in Exporting.
_meta.json
{
"trajectory_id": "3f9c0a1b7d2e4c55",
"planner": "tiptop",
"instruction": "place the bread inside the box",
"n_frames": 912, "fps": 15,
"cameras": {"exterior_image_1_left": "external_cam.mp4", "wrist_image_left": "hand_cam.mp4"},
"segments": [
{"source": "tamp", "timestamp": "2026-09-25_14-03-12", "phase_index": 0, "video_start": 0.0, "video_stop": 31.4},
{"source": "teleop", "timestamp": "2026-09-25_14-03-50", "phase_index": 1, "video_start": 31.4, "video_stop": 60.8}
]
}
A merged episode's _meta.json is the primary leg's, with the keys below set. A single-leg episode keeps its
own (per-leg keys).
| key | what it is |
|---|---|
trajectory_id |
Shared by every leg of the trial. |
planner, instruction |
The planner and the task (the language label). The session adds them if missing, and adds planner only on a planner's leg. tandem traj open picks its viewer by planner. |
source, segment_source, video_aligned |
"trajectory", null, true. |
n_frames, fps, total_video_frames |
Rows in robot_state.npz, the frame rate, and the frames in each joined video. |
record_start, record_stop |
Epoch seconds: the first leg's start and the last leg's stop. |
cameras |
Dataset key → video: exterior_image_1_left → external_cam.mp4, exterior_image_2_left → external_cam_2.mp4, wrist_image_left → hand_cam.mp4. |
n_phases |
Set only if every leg that states it agrees and all ran one plan. |
segments[] |
One entry per leg, in order (below). |
cameras_dropped, legs_skipped |
What was left out: videos only some legs had, and legs without robot_state.npz. |
frames_trimmed |
Leg → camera → trailing frames cut to even out that leg's videos. |
proportional_fallback_legs |
Legs with no usable recording window. Their frames are spread evenly over their videos. |
action_convention, action_notes |
Set with action_joint_velocity: "droid_joint_velocity", and leg → a note for each leg whose action was recomputed or could not be. |
Each segments[k] has:
source(tamp,teleoporpolicy),timestamp(the leg's directory name),n_framesandn_video_frames.video_startandvideo_stop(seconds into the joined videos), andrecord_startandrecord_stop.- If the leg stated them:
phase_index,phase_description,n_phasesandconfig_id(teleop:teleop/teleop).phase_indexandphase_descriptionnever appear at the top level. - After a
replan:plan_generation(0 is the first plan).
A conjoined robot leg gets the first phase it covers. Its phase_description joins the covered phases'
descriptions with ;.
hitl.json
This file is written when phase planning is on and the trial has a plan. It goes into the primary leg, then
beside the merged episode. A trial stopped at the label prompt keeps it in its primary leg in eval/.
A trimmed example, for a trial where the robot moves the bread and a person closes the lid:
{
"instruction": "put the bread in the box and close it",
"trajectory_id": "3f9c0a1b7d2e4c55",
"planner": "tiptop",
"human_executor": "teleop",
"outcome": "success", "excluded": false, "failure_stage": null, "filed_under": "success",
"specification": {
"invented_predicates": [{"name": "IsClosed", "types": ["surface"], "instructions": "Is the lid of {0} closed?"}],
"unrepresented": [],
"coverage": [{"clause": "put the bread in the box", "phase": 0}, {"clause": "close it", "phase": 1}]
},
"phases": [
{"index": 0, "executor": "robot", "description": "Put the bread in the box",
"atoms": ["On(bread, box)"], "goal": [{"predicate": "on", "args": ["bread", "box"]}],
"planning_seconds": 41.2},
{"index": 1, "executor": "human", "description": "Close the box",
"atoms": ["IsClosed(box)"], "instructions": "Close the box's lid.",
"carried_out": [{"attempt": 1, "carried_out_by": "teleop", "status": "done", "leg_recorded": true, "n_frames": 440}]}
],
"verifications": [
{"atom": "IsClosed(box)", "statement": "Is the lid of box closed?", "holds": true, "expected": true,
"satisfied": true, "role": "effect", "reason": "The lid is down.", "phase": 1}
],
"phase_index": 2
}
Top-level keys
| key | what it is |
|---|---|
instruction, trajectory_id, planner, human_executor |
The task as planned, the trial id, the planner, and hitl.human_executor. |
outcome, excluded |
success, failure, excluded or aborted. excluded is outcome == "excluded". |
failure_stage |
invention, tamp_planning, tamp_execution, verification, human_policy or null. It is always the loop's, whatever the label. |
filed_under |
success, failure, or null while in eval/. A relabel rewrites it. |
outcome_reason |
What was wrong, when the loop ended the trial. |
overruled |
Set after tandem traj relabel --force files a settled trial under success/ (outcome then reads success). It holds the loop's {outcome, excluded, failure_stage, by} (rules). |
plan_generation, leg_plan_generations |
0, plus one per replan. After a replan, leg directory → its plan. |
superseded_plans[] |
Plans a replan replaced, oldest first, as they stood (without filing keys), plus plan_generation and superseded_because. [] if none. |
initially_true |
Invented atoms true on the first image (hitl.classify_initial), else []. |
provenance |
Who produced each part, in words (below). |
handed_over_phases |
Robot phases the planner couldn't plan, given to a person (on_robot_phase_failure: teleop). |
phase_index |
How far the trial got. It equals the phase count if the plan finished. |
provenance has phases_and_their_order, phase_sub_goals, invented_predicates and human_instructions (the
model), robot_phases (the planner), who_does_what (TANDEM), and human_steps (the intended executor; each
phase's carried_out says who actually did it). human_operators and robot_operators are {by, signatures},
with signatures written Name(param: type).
specification
The proposal's reading of the instruction.
| key | what it is |
|---|---|
invented_predicates[] |
{name, types, instructions}. instructions is the classifier sentence, with {0}, {1}, … for arguments. |
human_operators[] |
{phase, name, args, signature, instance, preconditions, add_effects, delete_effects} for each human phase with an operator. Load one with tandem.planning.structs.HumanOperator.from_json. |
surfaces[], movables[] |
The object types in the task. |
unrepresented[] |
{clause, reason} for each clause the plan leaves out. |
coverage[] |
{clause, phase} for each clause. phase is -1 if left out. |
checks
| key | what it is |
|---|---|
human_preconditions, human_effects, tamp_preconditions, tamp_effects, plan_effects, verify_enforced, precondition_enforced, verify_final_phase |
The hitl switches as configured. |
initial_state_classified |
Whether the first image was classified. |
plan_effects_rechecked, plan_effects_warning |
Whether the contract check re-ran on the first image, and its finding (or null). |
unchecked_phases[] |
Phases accepted without a verdict. |
unrecorded_human_phases[] |
Human phases with no leg, and so no segment. |
phases[k]
Every phase has index, executor, description, atoms and planned_by. The rest depends on the kind:
| kind | extra keys |
|---|---|
| Robot | goal (the atoms given to the planner, in its spelling) and goal_description. Once planned: planning_seconds, plan_reused, task_plan (the operator sequence, if reported), and covers_phases (if one plan covered several). |
| Human | instructions and operator. Once run, carried_out[] has {attempt, carried_out_by, status, leg_recorded, n_frames} per attempt. carried_out_by is the executor, or by_hand (with status: null) if "done" came with no executor (status values). |
| Handed to a person | A human phase without operator, plus proposed_executor: "robot", handed_over_because and instructions_by: "tandem". |
| Any | unchecked (why), if its check could not run or had nothing a camera can judge. |
verifications[k]
One entry per camera verdict, failing ones included: {atom, statement, holds, expected, satisfied, role, reason,
phase}. role is effect, effect (deleted) or precondition.
Read satisfied. holds is what the model saw and expected is what the plan wanted, so
satisfied == (holds == expected). For a delete effect, holds: true is the failure.
Only the attempt that settled a phase is kept. For an excluded trial, those are the verdicts it was excluded on.
Earlier attempts are in the human_phase_verified events.
vlm/
vlm/
├── 001_task-plan_input.png
├── 001_task-plan_output.png
├── 002_classify-LidClosed-red_bin_input.png
├── 002_classify-LidClosed-red_bin_output.png
└── index.jsonl
This directory holds every model query of the trial. It is written when phase planning is on and
hitl.save_vlm_io is true (the default): first into the session directory, then copied into the episode when it is
filed. Each query leaves:
NNN_<label>_input.png: the image sent.NNN_<label>_output.png: that image above the answer. It is marked REJECTED if TANDEM rejected the answer, or CACHED if it was replayed fromhitl.cache_path.- An
index.jsonlline withseq,label,attempt,model,input_image,output_image,rejected(why, ornull),cached,promptandresponse.
The labels are task plan (the proposal), classify <atom> (a camera check), and detect objects (tandem plan
without --object). A retry adds _attempt<N>. NNN continues from the directory's highest number, so nothing
is overwritten.
The events file
{"event": "rollout_saved", "ts": 1790270037.40, "dir": "…/eval/2026-09-25_14-03-12", "n_frames": 12}
{"event": "human_phase_verified", "ts": 1790270101.12, "phase_index": 1, "attempt": 1, "ok": true, "verdicts": […]}
{"event": "trial_outcome", "ts": 1790270110.87, "outcome": "excluded", "failure_stage": "verification", "reason": "…"}
A session appends one JSON line per event to
~/.local/state/tandem/sessions/<profile>/<session id>/events.jsonl. A sidecar planner writes to it too. The web
UI streams each event on /api/sessions/{id}/stream, adding "type": "event" and "at": <ts>.
From the phase loop
| event | payload |
|---|---|
rollout_start |
dir, phase_index, n_phases; also movables and return_home if they were passed to the planner |
rollout_saved |
dir, n_frames |
phase_complete |
phase_index (the next one), n_phases: more phases remain |
phase_plan_failed |
reason, policy (on_robot_phase_failure), phase_index (null with phase planning off) |
instruction_not_fully_represented |
unrepresented: [{clause, reason}] |
awaiting_human_phase |
description, instructions, expected (in words), expected_atoms and expected_deleted_atoms (must, or must no longer, hold after), phase_index, n_phases, is_last_phase, operator |
human_phase_refused |
phase_index, executor, reason: while recording, an answer with no leg was turned down |
human_phase_by_hand |
phase_index, attempt: "done" with no executor was accepted, with no leg |
human_leg_ended |
executor, status, n_frames, dir, phase_index |
human_phase_verified |
phase_index, attempt, ok (true, false, or null if not judged, with skipped or unchecked saying why), verdicts |
phase_preconditions_checked |
phase_index, description, what (human phase or robot leg), ok, enforced, verdicts; unchecked if the check could not run |
phase_effects_checked |
The same, for a robot leg's effects (enforced is always false) |
teleop_handoff_warning |
message: the planner could not release the robot |
trial_outcome |
trajectory_id, outcome, failure_stage, excluded, reason, phase_index: only when the loop ended the trial |
From the session
Below, trial summary means trajectory_id, outcome, excluded, filed_under, failure_stage, reason and
labeled.
| event | payload |
|---|---|
session_start, session_end, awaiting_task |
None |
awaiting_label |
dir |
labeled, trial_excluded |
dir and the trial summary; labeled adds success |
trial_filed |
dir and the trial summary: the trial failed part-way or was aborted, and was filed unlabeled |
trial_unlabeled |
dir and the trial summary: the session stopped at the label prompt |
rollout_discarded |
The trial summary: nothing was recorded |
rollout_aborted |
Nothing after a preempt; reason if the session is stopping; error if the attempt failed |
teleop_switch_pending |
None: the operator asked for the arm, which is handed over at the next plan-step boundary |
teleop_handoff_start, awaiting_teleop_resume (trajectory_id), teleop_handoff_done |
A hand-off, from release to return |
Session summary
A session's live state. The web UI gets it from GET /api/sessions/{id}, and tandem collect saves it in
session-<id>.json. The common fields are below; there are more.
| field | what it is |
|---|---|
id, profile, task |
The session. |
state, error, end_reason |
Where it is (warming, rolling, awaiting_task, awaiting_human_phase, teleop_handoff, awaiting_label, stopped, failed, …), and how it ended. |
labeled, success, excluded, aborted, target |
Trial counts, and the target (--episodes, else task.target_episodes). |
last_trial, rollouts[] |
The last attempt's trial summary, and every trial so far ({dir, id, started_at, n_frames, status, success}). |
human_phase, phase_progress |
The phase on screen (description, instructions, expected, attempt, missing, …), and [phase index, n_phases]. |
Logs and session files
| path | what it holds |
|---|---|
~/.local/state/tandem/logs/session-<id>.json |
Written when tandem collect ends (not for web UI sessions): summary (the session summary) and logs (the last 4,000 log lines). |
~/.local/state/tandem/sessions/<profile>/<id>/events.jsonl |
The events file. |
…/<id>/vlm/<trajectory id>/ |
Each trial's model queries. This is the only copy if the trial recorded nothing. |
…/<id>/perception/<leg dir>/ |
Perception passes that recorded nothing, moved out of eval/. TiPToP's metadata.json there has the planning result and failure reason. |
…/<id>/teleop/leg-*/teleop-events.jsonl |
Teleop driver events, one directory per hand-off. |
~/.local/state/tandem/logs/server-<name>.log |
A perception server TANDEM started (m2t2, foundation_stereo), appended per start. Its pid is in ~/.local/state/tandem/servers/<name>.pid while it runs. |
~/.local/state/tandem/logs/export.log |
tandem export lerobot's log, appended on each run. |
~/.local/state/tandem/logs/runtime-build-<YYYYMMDD-HHMMSS>.log |
One per runtime build (tandem planners install, tandem runtime build, tandem init). |
~/tandem-data/exports/<owner>/<name>/ |
Exported datasets, or --out DIR (exporting). |
~/tandem-data/trajectories/<profile>/.merge-<trajectory id>/ |
A merge in progress. If one is left behind, the next tandem traj merge says how to move its legs back. |
A trial that failed at invention usually has no legs. Only its events file and its session vlm/ and
perception/ directories remain.