diff --git a/bin/slurm_calibrate.sh b/bin/slurm_calibrate.sh
index 05a04cba4e88fec483f37b293e8aba569b7f5a1a..9e5088757b161bb0206da82517a2f1fd6210184a 100755
--- a/bin/slurm_calibrate.sh
+++ b/bin/slurm_calibrate.sh
@@ -10,6 +10,7 @@ caltype=$6
 final=$7
 finalize=$8
 cluster_cores=$9
+notebook_python_path=$10
 
 echo "Running with the following parameters:"
 echo "Notebook path: $nb_path"
@@ -43,8 +44,8 @@ fi
 
 
 echo "Running notebook"
-${python_path} -m princess ${nb_path} --save --on-error-resume-next
-${python_path} -m nbconvert --to rst --TemplateExporter.exclude_input=True ${nb_path}
+${notebook_python_path} -m princess ${nb_path} --save --on-error-resume-next
+${notebook_python_path} -m nbconvert --to rst --TemplateExporter.exclude_input=True ${nb_path}
 
 # stop the cluster if requested
 if [ "${ipcluster_profile}" != "NO_CLUSTER" ]
diff --git a/notebooks/test/test-cli.ipynb b/notebooks/test/test-cli.ipynb
index ef866040aa10b5ebfb5df881f3c493c4f3724652..2874fa87593b5d841e964d74920156a1896e1758 100644
--- a/notebooks/test/test-cli.ipynb
+++ b/notebooks/test/test-cli.ipynb
@@ -19,8 +19,11 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "in_folder = \"/dev/null\" # input folder\n",
-    "out_folder = \"/dev/null\" # output folder\n",
+    "root = \"root/path/for/nb\"  # Variables included in the user notebook path must\n",
+    "# be included in the notebook for reasons\n",
+    "\n",
+    "in_folder = \"./\" # input folder\n",
+    "out_folder = \"./\" # output folder\n",
     "list_normal = [10] # parameterized list, range allowed\n",
     "list_intellilist = [2345] # parameterized list with ranges, range allowed\n",
     "concurrency_parameter = [1] # concurrency parameter, range allowed\n",
@@ -56,15 +59,9 @@
   }
  ],
  "metadata": {
-  "kernelspec": {
-   "display_name": "Python 3.6.8 64-bit",
-   "name": "python3"
-  },
-  "language_info": {
-   "name": "python",
-   "version": ""
-  }
+  "language_info": {},
+  "orig_nbformat": 3
  },
  "nbformat": 4,
  "nbformat_minor": 0
-}
+}
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 7a65bbfa5b7dc6d52ebce011678d601677284a85..51e7fa68530c330db255bf5d2db6b01781ce8c39 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@ from subprocess import check_output
 import numpy
 from Cython.Build import cythonize
 from Cython.Distutils import build_ext
-from setuptools import setup, find_packages
+from setuptools import find_packages, setup
 
 from src.xfel_calibrate.notebooks import notebooks
 
@@ -36,10 +36,12 @@ class PreInstallCommand(build):
 data_files = []
 for ctypes in notebooks.values():
     for nb in ctypes.values():
-        data_files.append(nb["notebook"])
+        data_files.append(nb.get("notebook"))
         data_files += nb.get("dep_notebooks", [])
         data_files += nb.get("pre_notebooks", [])
 
+data_files = list(filter(None, data_files))  # Get rid of `None` entries
+
 setup(
     name="European XFEL Offline Calibration",
     version="1.0",
diff --git a/src/xfel_calibrate/calibrate.py b/src/xfel_calibrate/calibrate.py
index 951dba8ea94b711cff5ce1796b7486633696f107..4068a07b66bfc13128dfcf01dc7d57a58a740ef8 100755
--- a/src/xfel_calibrate/calibrate.py
+++ b/src/xfel_calibrate/calibrate.py
@@ -1,6 +1,7 @@
 #!/usr/bin/env python
 
 import argparse
+import ast
 import inspect
 import math
 import os
@@ -13,7 +14,7 @@ import warnings
 from datetime import datetime
 from pathlib import Path
 from subprocess import DEVNULL, check_output
-from typing import List, Union
+from typing import List, Optional, Union
 
 import nbformat
 import numpy as np
@@ -359,9 +360,25 @@ def make_extended_parser() -> argparse.ArgumentParser:
         # The information is extracted from the first markdown cell of
         # the notebook.
         for caltype, notebook in det_notebooks.items():
-            nbpath = os.path.join(PKG_DIR, notebook["notebook"])
-            nb = nbformat.read(nbpath, as_version=4)
-            msg += make_epilog(nb, caltype=caltype)
+            if notebook.get("notebook") is None:
+                if notebook.get("user", {}).get("notebook") is None:
+                    raise KeyError(
+                        f"`{detector}` does not have a notebook path, for "
+                        "notebooks that are stored in pycalibration set the "
+                        "`notebook` key to a relative path or set the "
+                        "`['user']['notebook']` key to an absolute path/path "
+                        "pattern. Notebook configuration dictionary contains "
+                        f"only: `{notebook}`"
+                        ""
+                    )
+                #  Everything should be indented by 17 spaces
+                msg += caltype.ljust(17) + "User defined notebook, arguments may vary\n"
+                msg += " "*17 + "User notebook expected to be at path:\n"
+                msg += " "*17 + notebook["user"]["notebook"] + "\n"
+            else:
+                nbpath = os.path.join(PKG_DIR, notebook["notebook"])
+                nb = nbformat.read(nbpath, as_version=4)
+                msg += make_epilog(nb, caltype=caltype)
 
         return make_initial_parser(epilog=msg)
     elif len(sys.argv) <= 3:
@@ -376,12 +393,37 @@ def make_extended_parser() -> argparse.ArgumentParser:
         print("Not one of the known calibrations or detectors")
         sys.exit(1)
 
-    notebook = os.path.join(PKG_DIR, nb_info["notebook"])
-    cvar = nb_info.get("concurrency", {}).get("parameter", None)
+    if nb_info["notebook"]:
+        notebook = os.path.join(PKG_DIR, nb_info["notebook"])
+    else:
+        # If `"notebook"` entry is None, then set it to the user provided
+        # notebook TODO: This is a very hacky workaround, better implementation
+        # is not really possible with the current state of this module
+        user_notebook_path = nb_info["user"]["notebook"]
+        #  Pulls out the variables in the templated path string, so that they
+        #  can be added to the argument parser
+        user_notebook_variables = [
+            k.value.id
+            for k
+            in ast.walk(ast.parse(f"f'{user_notebook_path}'"))
+            if isinstance(k, ast.FormattedValue)
+        ]
+
+        user_notebook_parser = argparse.ArgumentParser()
+
+        for var in user_notebook_variables:
+            user_notebook_parser.add_argument(f"--{var}")
+
+        user_notebook_args, _ = user_notebook_parser.parse_known_args(
+            args=list(filter(lambda x: x != "-h", _))  # Drop help from args
+        )
 
-    nb = nbformat.read(notebook, as_version=4)
+        nb_info["notebook"] = nb_info["user"]["notebook"].format(**vars(user_notebook_args))
+        notebook = nb_info["notebook"]
 
+    cvar = nb_info.get("concurrency", {}).get("parameter", None)
 
+    nb = nbformat.read(notebook, as_version=4)
 
     # extend parameters if needed
     ext_func = nb_info.get("extend parms", None)
@@ -663,10 +705,12 @@ def remove_duplications(l):
     return unique_l
 
 
-def concurrent_run(temp_path, nb, nbname, args, cparm=None, cval=None,
-                   final_job=False, job_list=[], fmt_args={}, cluster_cores=8,
-                   sequential=False, dep_jids=[],
-                   show_title=True):
+def concurrent_run(
+    temp_path, nb, nbname, args, cparm=None, cval=None,
+    final_job=False, job_list=[], fmt_args={}, cluster_cores=8,
+    sequential=False, dep_jids=[],
+    show_title=True, user_venv: Optional[Path] = None,
+):
     """ Launch a concurrent job on the cluster via SLURM
     """
 
@@ -707,16 +751,22 @@ def concurrent_run(temp_path, nb, nbname, args, cparm=None, cval=None,
         srun_base = get_launcher_command(args, temp_path, dep_jids)
         print(" ".join(srun_base))
 
-    srun_base += [os.path.join(PKG_DIR, "bin", "slurm_calibrate.sh"),  # path to helper sh
-                  os.path.abspath(nbpath),  # path to notebook
-                  python_path,  # path to python
-                  cluster_profile,
-                  '"{}"'.format(base_name.upper()),
-                  '"{}"'.format(args["detector"].upper()),
-                  '"{}"'.format(args["type"].upper()),
-                  "FINAL" if final_job else "NONFINAL",
-                  "{}/finalize.py".format(os.path.abspath(temp_path)),
-                  str(cluster_cores)]
+    if user_venv:
+        print(f"Running job in user venv at {user_venv}\n")
+
+    srun_base += [
+        os.path.join(PKG_DIR, "bin", "slurm_calibrate.sh"),  # path to helper sh
+        os.path.abspath(nbpath),  # path to notebook
+        python_path,  # path to python
+        cluster_profile,
+        '"{}"'.format(base_name.upper()),
+        '"{}"'.format(args["detector"].upper()),
+        '"{}"'.format(args["type"].upper()),
+        "FINAL" if final_job else "NONFINAL",
+        "{}/finalize.py".format(os.path.abspath(temp_path)),
+        str(cluster_cores),
+        user_venv + "/bin/python" if user_venv else python_path  # used for nb execution
+    ]
 
     output = check_output(srun_base).decode('utf8')
     jobid = None
@@ -924,6 +974,10 @@ def run():
                 'submission_time': submission_time
                 }
 
+    user_venv = None
+    if nb_info.get("user", {}).get("venv"):
+        user_venv = nb_info["user"]["venv"].format(**args)
+
     joblist = []
     cluster_cores = concurrency.get("cluster cores", 8)
     # Check if there are pre-notebooks
@@ -935,7 +989,7 @@ def run():
                                args,
                                job_list=joblist, fmt_args=fmt_args,
                                cluster_cores=cluster_cores,
-                               sequential=sequential,
+                               sequential=sequential, user_venv=user_venv
                               )
         joblist.append(jobid)
 
@@ -946,7 +1000,7 @@ def run():
                                fmt_args=fmt_args,
                                cluster_cores=cluster_cores,
                                sequential=sequential,
-                               dep_jids=joblist,
+                               dep_jids=joblist, user_venv=user_venv
                               )
         joblist.append(jobid)
     else:
diff --git a/src/xfel_calibrate/notebooks.py b/src/xfel_calibrate/notebooks.py
index a98aeef536ec49d0a51cccb2abe789203c1c935a..b9ccbab6c2c09da5f60cdedcdc3e1175468373a0 100644
--- a/src/xfel_calibrate/notebooks.py
+++ b/src/xfel_calibrate/notebooks.py
@@ -239,6 +239,21 @@ notebooks = {
                             "cluster cores": 16},
         },
     },
+    "REMI": {
+        "CORRECT": {
+            "notebook": None,
+            "user": {
+                "notebook": "/gpfs/exfel/exp/{instrument}/{cycle}/p{proposal}/usr/calibration/notebooks/correct.ipynb",
+                "venv": "/gpfs/exfel/sw/software/exfel_environments/sqs-remi-preview"
+            },
+            "concurrency": {
+                "parameter": None,
+                "use function": None,
+                "default concurrency": None,
+                "cluster cores": 1
+            },
+        },
+    },
     "TEST": {
         "TEST-CLI": {
             "notebook": "notebooks/test/test-cli.ipynb",
@@ -248,5 +263,36 @@ notebooks = {
                 "cluster cores": 1,
             },
         },
+        "TEST-USER-NB": {
+            "notebook": None,
+            "user": {
+                "notebook": "/{root}/test-cli.ipynb",
+                "venv": None,  # default, pycalibration environment
+            },
+            "concurrency": {
+                "parameter": None,
+                "use function": None,
+                "default concurrency": None,
+                "cluster cores": 1
+            },
+        },
+        "TEST-USER-NB-VENV": {
+            "notebook": None,
+            "user": {
+                "notebook": "/{root}/test-cli.ipynb",
+                "venv": "/{root}/.venv",
+            },
+            "concurrency": {
+                "parameter": None,
+                "use function": None,
+                "default concurrency": None,
+                "cluster cores": 1
+            },
+        },
+    },
+    "TEST-RAISES-ERRORS": {
+        "TEST-BAD-KEY": {
+            "noteboke": "a typo",
+        }
     },
 }
diff --git a/tests/test_xfel_calibrate/conftest.py b/tests/test_xfel_calibrate/conftest.py
index c00cfda97c78621930ce5fe2797f94b66ed9068e..61fb9c6f1fb24e61c59bc8fcfe6c66ad321c36ee 100644
--- a/tests/test_xfel_calibrate/conftest.py
+++ b/tests/test_xfel_calibrate/conftest.py
@@ -99,6 +99,11 @@ class MockProposal:
         self.path = tmp_path / instrument / cycle / proposal
         self.path_raw = self.path / "raw"
         self.path_proc = self.path / "proc"
+        self.path_usr = self.path / "usr"
+        self.path_scratch = self.path / "scratch"
+
+        self.path_usr.mkdir(parents=True)
+        self.path_scratch.mkdir()
 
         self.runs = self.create_runs(runs)
 
diff --git a/tests/test_xfel_calibrate/test_cli.py b/tests/test_xfel_calibrate/test_cli.py
index f0ffeb4af21ededff97abe66e22f669266517e6d..4a75d6242c97644a249379cce32a6ec93b38c622 100644
--- a/tests/test_xfel_calibrate/test_cli.py
+++ b/tests/test_xfel_calibrate/test_cli.py
@@ -55,6 +55,23 @@ class TestBasicCalls:
 
         assert err == ""
 
+    @mock.patch.object(sys, "argv", ["xfel-calibrate", "TEST", "-h"])
+    def test_help_user_notebook(self, capsys):
+        with pytest.raises(SystemExit):
+            calibrate.run()
+
+        out, err = capsys.readouterr()
+
+        assert "TEST-USER-NB" in out
+        assert "/{root}/test-cli.ipynb" in out
+
+        assert err == ""
+
+    @mock.patch.object(sys, "argv", ["xfel-calibrate", "TEST-RAISES-ERRORS", "--help"])
+    def test_help_bad_config(self):
+        with pytest.raises(KeyError):
+            calibrate.run()
+
     @mock.patch.object(sys, "argv", ["xfel-calibrate", "NotADetector", "beep", "-h"])
     def test_unknown_detector(self, capsys):
         with pytest.raises(SystemExit) as exit_exception:
diff --git a/tests/test_xfel_calibrate/test_user_configs.py b/tests/test_xfel_calibrate/test_user_configs.py
new file mode 100644
index 0000000000000000000000000000000000000000..e064ca7311e0f1895169c8166bbf7845c8a9585a
--- /dev/null
+++ b/tests/test_xfel_calibrate/test_user_configs.py
@@ -0,0 +1,144 @@
+# pylint: disable=missing-class-docstring, missing-function-docstring, no-self-use
+""" Tests for user configurable notebooks
+
+These tests cover functionality enabled by setting `"notebook": None` and
+instead providing path templates under the `user` key in the `notebooks.py`
+configuration file.
+"""
+import shlex
+import shutil
+from pathlib import Path
+
+import nbformat
+import pytest
+from nbparameterise import extract_parameters
+
+from tests.test_xfel_calibrate.conftest import (
+    CalibrateCall,
+    FakeProcessCalibrate,
+    MockProposal,
+)
+
+TEST_CLI_PATH = Path("./notebooks/test/test-cli.ipynb")
+
+
+class TestUserNotebooks:
+    user_nb = None  # Gets set in `self.calibrate_call`
+
+    @pytest.fixture(scope="class", autouse=True)
+    def fake_process_calibrate(self):
+        with FakeProcessCalibrate() as fake_process:
+            yield fake_process
+
+    @pytest.fixture(scope="class")
+    def mock_proposal(self, tmpdir_factory):
+        return MockProposal(
+            tmp_path=Path(tmpdir_factory.mktemp("exp")),
+            instrument="AGIPD",
+            cycle="202031",
+            proposal="p900113",
+            runs=1,
+            sequences=1,
+        )
+
+    @pytest.fixture(scope="function")
+    def calibrate_call(self, mock_proposal: MockProposal, capsys, tmp_path: Path):
+        # The user notebook path template for `TEST`/`TEST-USER-NB` is:
+        #    `"/{instrument}/{cycle}/p{proposal}/test-cli.ipynb"`
+        #
+        # So by setting `instrument` to tmp_path/TEST we set the user notebook
+        # location to one in our tmp dir
+
+        self.user_nb = mock_proposal.path_usr / "test-cli.ipynb"
+
+        shutil.copy(TEST_CLI_PATH.absolute(), str(self.user_nb))
+
+        return CalibrateCall(
+            tmp_path,
+            capsys,
+            in_folder=mock_proposal.path_raw,
+            out_folder=mock_proposal.path_proc,
+            detector="TEST",
+            cal_type="TEST-USER-NB",
+            extra_args=[
+                "--root",
+                str(mock_proposal.path_usr),
+                "--number",
+                "10",
+            ],
+        )
+
+    def test_user_notebook_is_test_notebook(self, calibrate_call: CalibrateCall):
+        assert TEST_CLI_PATH.read_bytes() == self.user_nb.read_bytes()
+
+    def test_output_ipynb(self, calibrate_call: CalibrateCall):
+        notebook_path = calibrate_call.paths.notebooks
+        assert len(notebook_path) == 1
+
+        with notebook_path[0].open() as file:
+            notebook = nbformat.read(file, as_version=4)
+
+        parameters = {p.name: p.value for p in extract_parameters(notebook)}
+
+        assert parameters["number"] == 10
+
+
+class TestUserVenv:
+    @pytest.fixture(scope="class", autouse=True)
+    def fake_process_calibrate(self):
+        with FakeProcessCalibrate() as fake_process:
+            yield fake_process
+
+    @pytest.fixture(scope="class")
+    def mock_proposal(self, tmpdir_factory):
+        return MockProposal(
+            tmp_path=Path(tmpdir_factory.mktemp("exp")),
+            instrument="AGIPD",
+            cycle="202031",
+            proposal="p900113",
+            runs=1,
+            sequences=1,
+        )
+
+    @pytest.fixture(scope="function")
+    def calibrate_call(self, mock_proposal: MockProposal, capsys, tmp_path: Path):
+        self.user_nb = mock_proposal.path_usr / "test-cli.ipynb"
+
+        shutil.copy(TEST_CLI_PATH.absolute(), str(self.user_nb))
+
+        return CalibrateCall(
+            tmp_path,
+            capsys,
+            in_folder=mock_proposal.path_raw,
+            out_folder=mock_proposal.path_proc,
+            detector="TEST",
+            cal_type="TEST-USER-NB-VENV",
+            extra_args=[
+                "--root",
+                str(mock_proposal.path_usr),
+                "--number",
+                "10",
+            ],
+        )
+
+    def test_call(self, calibrate_call: CalibrateCall):
+        assert "Running job in user venv at" in calibrate_call.out
+
+    def test_expected_processes_called(
+        self,
+        mock_proposal: MockProposal,
+        fake_process_calibrate: FakeProcessCalibrate,
+    ):
+        process_calls = [
+            list(shlex.shlex(p, posix=True, punctuation_chars=True))
+            if isinstance(p, str)
+            else p
+            for p in fake_process_calibrate.calls
+        ]
+
+        processes_called = [p[0] for p in process_calls]  # List of the process names
+
+        assert "sbatch" in processes_called
+
+        sbatch_cmd = process_calls[processes_called.index("sbatch")]
+        assert any(str(mock_proposal.path_usr / ".venv") in arg for arg in sbatch_cmd)