diff --git a/src/xfel_calibrate/repeat.py b/src/xfel_calibrate/repeat.py
new file mode 100644
index 0000000000000000000000000000000000000000..580b1d3e8b177529202c620e20787488a4b43c6d
--- /dev/null
+++ b/src/xfel_calibrate/repeat.py
@@ -0,0 +1,40 @@
+import argparse
+import json
+import sys
+from datetime import datetime
+from pathlib import Path
+from shutil import copytree
+
+from .calibrate import Step, JobGroup, SlurmOptions
+from .settings import temp_path
+
+def main(argv=None):
+    ap = argparse.ArgumentParser()
+    ap.add_argument("from_dir", help="A directory containing exec_details.json")
+    ap.add_argument("--python", help="Path to Python executable to run notebooks")
+    ap.add_argument("--slurm-partition", help="Submit jobs in this Slurm partition")
+    ap.add_argument('--no-cluster-job', action="store_true",
+                    help="Run notebooks here, not in cluster jobs")
+    args = ap.parse_args(argv)
+
+    run_uuid = f"t{datetime.now():%y%m%d_%H%M%S}"
+
+    working_dir = Path(temp_path, f'slurm_out_repeat_{run_uuid}')
+    copytree(args.from_dir, working_dir)
+
+    exec_details = json.loads((working_dir / 'exec_details.json').read_text('utf-8'))
+
+    job_group = JobGroup(
+        [Step.from_dict(d) for d in exec_details['steps']],
+        run_tmp_path=str(working_dir),
+        python=(args.python or sys.executable),
+    )
+    if args.no_cluster_job:
+        job_group.run_direct()
+    else:
+        job_group.submit_jobs(SlurmOptions(
+            partition=args.slurm_partition,
+        ))
+
+if __name__ == '__main__':
+    sys.exit(main())