Repository: omnivector-solutions/jobbergate (language: python)

Issue:
[ASP-4586] Submit jobs on behalf of other users with jobbergate-agent -- CLI
#### What
- Dropped support for Python 3.8 and 3.9
- Added Jobbergate-core as a project dependency to reuse key components
- Refactored on-site submission to use the new `jobbergate-core` components
- Replaced `execution_parameters` by `sbatch_arguments` on job submission
- Refactored `jobbergate_cli.subapps.job_submissions.tools.create_submission` into two classes in the same module to reduce complexity in the codebase

#### Why
This is the work on the CLI side to replace slurmrestd by sbatch. Additional changes were made to reduce complexity and duplication on the codebase, besides to increase test coverage.

`Task`: https://jira.scania.com/browse/ASP-4586

---

#### Peer Review
Please follow the upstream omnivector documentation concerning
[peer-review guidelines](https://github.com/omnivector-solutions/Documentation/blob/main/Contributing/pr_review_standards.md#peer-review).



Discussion / hints:

Fix patch:
diff --git a/examples/simple-application/jobbergate.py b/examples/simple-application/jobbergate.py
index e89bef74..8dd1fae6 100644
--- a/examples/simple-application/jobbergate.py
+++ b/examples/simple-application/jobbergate.py
@@ -6,7 +6,7 @@ class JobbergateApplication(JobbergateApplicationBase):
 
     def mainflow(self, data=None):
         if data is None:
-            data=dict()
+            data = dict()
         data["nextworkflow"] = "subflow"
         return [
             Text("foo", message="gimme the foo!", default="foo"),
@@ -15,8 +15,8 @@ def mainflow(self, data=None):
 
     def subflow(self, data=None):
         if data is None:
-            data=dict()
+            data = dict()
         return [
             Text("baz", message="gimme the baz!", default="zab"),
-            Text("workdir", message="gimme the workdir!", default="/nfs"),
+            Text("filename", message="gimme the filename!", default="dummy-result.txt"),
         ]
diff --git a/examples/simple-application/jobbergate.yaml b/examples/simple-application/jobbergate.yaml
index 3b0e3b8c..9c54e71e 100644
--- a/examples/simple-application/jobbergate.yaml
+++ b/examples/simple-application/jobbergate.yaml
@@ -5,4 +5,4 @@ application_config:
   foo: FOO
   bar: BAR
   baz: BAZ
-  workdir: /nfs
+  filename: dummy-result.txt
diff --git a/examples/simple-application/templates/dummy-script.py.j2 b/examples/simple-application/templates/dummy-script.py.j2
index aa13d2c1..ef1d524f 100644
--- a/examples/simple-application/templates/dummy-script.py.j2
+++ b/examples/simple-application/templates/dummy-script.py.j2
@@ -4,7 +4,7 @@
 #SBATCH -t 60
 
 print("Executing dummy job script")
-with open("{{ data['workdir'] }}/dummy-output.txt", mode="w") as dump_file:
+with open("{{ data['filename'] }}", mode="w") as dump_file:
     print("I am a very, very dumb job script", file=dump_file)
     print("foo={{ data['foo'] }}", file=dump_file)
     print("bar={{ data['bar'] }}", file=dump_file)
diff --git a/fake-sbatch/fake_sbatch/main.py b/fake-sbatch/fake_sbatch/main.py
index 4461ccbd..a957f47a 100644
--- a/fake-sbatch/fake_sbatch/main.py
+++ b/fake-sbatch/fake_sbatch/main.py
@@ -16,8 +16,8 @@ def submit():
         raise typer.Exit(code=1)
 
     fake_slurm_job_id = randint(settings.FAKE_SBATCH_MIN_JOB_ID, settings.FAKE_SBATCH_MAX_JOB_ID)
-    typer.echo(f"fake-sbatch: Submitted batch job {fake_slurm_job_id}")
+    typer.echo(f"{fake_slurm_job_id},fake-sbatch-cluster")
 
 
-if __name__ == '__main__':
+if __name__ == "__main__":
     app()
diff --git a/jobbergate-agent/CHANGELOG.md b/jobbergate-agent/CHANGELOG.md
index fd61d467..34cc1755 100644
--- a/jobbergate-agent/CHANGELOG.md
+++ b/jobbergate-agent/CHANGELOG.md
@@ -4,6 +4,9 @@ This file keeps track of all notable changes to jobbergate-core
 
 ## Unreleased
 
+- Dropped support for Python 3.8 and 3.9
+- Replaced slurmrestd as the way the agent interacts with slurm by using sbatch to submit jobs and scontrol to get information about them [ASP-4585]
+
 ## 4.4.0 -- 2024-03-19
 
 - Keep version in sync with the other components.
diff --git a/jobbergate-agent/Dockerfile.dev b/jobbergate-agent/Dockerfile.dev
index ac746417..94901038 100644
--- a/jobbergate-agent/Dockerfile.dev
+++ b/jobbergate-agent/Dockerfile.dev
@@ -1,13 +1,13 @@
-FROM python:3.12-slim-bookworm
+FROM slurm-docker-cluster
 
 WORKDIR /app
 
-RUN apt update && apt install -y curl libpq-dev gcc
+RUN apt update && apt install -y curl libpq-dev gcc python3-dev python3-pip && \
+    ln -s /usr/bin/python3 /usr/bin/python
 
 RUN curl -sSL  https://install.python-poetry.org | \
     POETRY_HOME=/opt/poetry POETRY_VERSION=1.5.1 python && \
-    cd /usr/local/bin && \
-    ln -s /opt/poetry/bin/poetry && \
+    ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry && \
     poetry config virtualenvs.create false
 
 COPY ./pyproject.toml ./poetry.lock* ./README* ./LICENSE* /app/
diff --git a/jobbergate-agent/README.md b/jobbergate-agent/README.md
index c5c85680..af3f2aaa 100644
--- a/jobbergate-agent/README.md
+++ b/jobbergate-agent/README.md
@@ -18,10 +18,7 @@ To install the package from Pypi simply run `pip install jobbergate-agent`.
 
     ```bash
     JOBBERGATE_AGENT_BASE_API_URL="<base-api-url>"
-    JOBBERGATE_AGENT_BASE_SLURMRESTD_URL="<slurmrestd-endpoint>"
-    JOBBERGATE_AGENT_X_SLURM_USER_NAME="<slurmrestd-user-name>"
-    JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_PATH="/path/to/the/jwt/secret/key"
-    JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_STRING="jwt-secret-key-in-plain-text"
+    JOBBERGATE_AGENT_X_SLURM_USER_NAME="<sbatch-user-name>"
     JOBBERGATE_AGENT_SENTRY_DSN="<sentry-dsn-key>"
     JOBBERGATE_AGENT_OIDC_DOMAIN="<OIDC-domain>"
     JOBBERGATE_AGENT_OIDC_AUDIENCE="<OIDC-audience>"
@@ -31,8 +28,6 @@ To install the package from Pypi simply run `pip install jobbergate-agent`.
 
     **Note**: `JOBBERGATE_AGENT_SENTRY_DSN` is optional. If you do not pass it the agent understands Sentry will not be used.
 
-    **Note**: When both `JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_PATH` and `JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_STRING` are passed, the agent will completely ignore the `JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_PATH` and will prioritize the `JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_STRING`. Beware this behaviour.
-
 ## Local usage example
 
 1. Run app
diff --git a/jobbergate-agent/etc/entrypoint.sh b/jobbergate-agent/etc/entrypoint.sh
index 7daf2e22..2b846749 100755
--- a/jobbergate-agent/etc/entrypoint.sh
+++ b/jobbergate-agent/etc/entrypoint.sh
@@ -1,6 +1,23 @@
 #!/bin/bash
 
 set -e
+
+echo "---> Starting the MUNGE Authentication service (munged) ..."
+service munge start
+
+echo "---> Waiting for slurmdbd to become active before starting slurmctld ..."
+
+until 2>/dev/null >/dev/tcp/slurmdbd/6819
+do
+    echo "-- slurmdbd is not available.  Sleeping ..."
+    sleep 2
+done
+echo "-- slurmdbd is now active ..."
+
+echo "---> Starting the Slurm Controller Daemon (slurmctld) ..."
+gosu slurm /usr/sbin/slurmctld -Dvvv &
+
+echo "---> Starting Jobbergate-agent ..."
 cd /app
 poetry install
-poetry run jg-run
+poetry run jg-run
\ No newline at end of file
diff --git a/jobbergate-agent/jobbergate_agent/clients/cluster_api.py b/jobbergate-agent/jobbergate_agent/clients/cluster_api.py
index 923a0090..af8fa5bd 100644
--- a/jobbergate-agent/jobbergate_agent/clients/cluster_api.py
+++ b/jobbergate-agent/jobbergate_agent/clients/cluster_api.py
@@ -1,4 +1,5 @@
 """Core module for Jobbergate API clients management"""
+
 import httpx
 import sentry_sdk
 from jobbergate_core.auth.token import Token, TokenError, TokenType
diff --git a/jobbergate-agent/jobbergate_agent/clients/slurmrestd.py b/jobbergate-agent/jobbergate_agent/clients/slurmrestd.py
deleted file mode 100644
index 08672b00..00000000
--- a/jobbergate-agent/jobbergate_agent/clients/slurmrestd.py
+++ /dev/null
@@ -1,132 +0,0 @@
-"""Core module for Jobbergate API clients management"""
-
-import typing
-from datetime import datetime, timedelta
-
-import httpx
-import sentry_sdk
-from buzz import enforce_defined
-from jobbergate_core.auth.token import Token, TokenError
-from jose import jwt
-
-from jobbergate_agent.settings import SETTINGS
-from jobbergate_agent.utils.logging import logger
-
-
-CACHE_DIR = SETTINGS.CACHE_DIR / "slurmrestd"
-
-
-def acquire_token(username: str) -> str:
-    """
-    Retrieves a token from Slurmrestd based on the app settings.
-    """
-    logger.debug("Attempting to use cached token")
-    base_token = Token(cache_directory=CACHE_DIR, label=username)
-
-    try:
-        token = base_token.load_from_cache()
-        if token.is_valid():
-            return token.content
-    except TokenError as e:
-        logger.debug("Failed to load token from cache: {}", e)
-
-    logger.debug("Attempting to generate token for Slurmrestd")
-
-    if SETTINGS.SLURMRESTD_USE_KEY_PATH and SETTINGS.SLURMRESTD_JWT_KEY_PATH:
-        secret_key = open(SETTINGS.SLURMRESTD_JWT_KEY_PATH, "r").read()
-    else:
-        secret_key = enforce_defined(
-            SETTINGS.SLURMRESTD_JWT_KEY_STRING,
-            "SLURMRESTD_JWT_KEY_STRING is not defined",
-        )
-
-    now = datetime.now()
-    payload = {
-        "exp": int(datetime.timestamp(now + timedelta(seconds=SETTINGS.SLURMRESTD_EXP_TIME_IN_SECONDS))),
-        "iat": int(datetime.timestamp(now)),
-        "sun": username,
-    }
-    token_content = jwt.encode(payload, secret_key, algorithm="HS256")
-
-    token = base_token.replace(content=token_content)
-    token.cache_directory.mkdir(parents=True, exist_ok=True)
-    token.save_to_cache()
-
-    logger.debug("Successfully generated auth token")
-    return token.content
-
-
-def inject_token(
-    request: httpx.Request,
-    username: typing.Optional[str] = None,
-) -> httpx.Request:
-    """
-    Inject a token based on the provided username into the request.
-
-    For requests that need to use something except the default username,
-    this injector should be used at the request level (instead of at client
-    initialization) like this:
-
-    .. code-block:: python
-
-       client.get(url, auth=lambda r: inject_token(r, username=username))
-    """
-    if username is None:
-        username = SETTINGS.X_SLURM_USER_NAME
-
-    token = SETTINGS.X_SLURM_USER_TOKEN
-    if token is None:
-        token = acquire_token(username)
-
-    request.headers["x-slurm-user-name"] = username
-    request.headers["x-slurm-user-token"] = token
-    return request
-
-
-class AsyncBackendClient(httpx.AsyncClient):
-    """
-    Extends the httpx.AsyncClient class with automatic token acquisition for requests.
-    The token is acquired lazily on the first httpx request issued.
-    This client should be used for most agent actions.
-    """
-
-    _token: typing.Optional[str]
-
-    def __init__(self):
-        if SETTINGS.SLURM_RESTD_VERSIONED_URL is None:
-            raise ValueError("SLURM_RESTD_VERSIONED_URL must be set in order to use the AsyncBackendClient")
-        super().__init__(
-            base_url=SETTINGS.SLURM_RESTD_VERSIONED_URL,
-            auth=inject_token,
-            event_hooks=dict(
-                request=[self._log_request],
-                response=[self._log_response],
-            ),
-            timeout=SETTINGS.REQUESTS_TIMEOUT,
-        )
-
-    @staticmethod
-    async def _log_request(request: httpx.Request):
-        logger.debug(f"Making request: {request.method} {request.url}")
-
-    @staticmethod
-    async def _log_response(response: httpx.Response):
-        logger.debug(
-            f"Received response: {response.request.method} " f"{response.request.url} " f"{response.status_code}"
-        )
-
-    async def request(self, *args, **kwargs):
-        """
-        Request wrapper that captures request errors and sends them to Sentry.
-
-        This ensures events are sent to Sentry even if the caller handles the exception.
-        """
-        try:
-            return await super().request(*args, **kwargs)
-        except Exception as err:
-            sentry_sdk.capture_exception(err)
-            logger.error(f"Request to Slurm-API failed: {err}")
-            raise err
-
-
-backend_client = AsyncBackendClient()
diff --git a/jobbergate-agent/jobbergate_agent/jobbergate/schemas.py b/jobbergate-agent/jobbergate_agent/jobbergate/schemas.py
index 19bdaa1a..177ebd00 100644
--- a/jobbergate-agent/jobbergate_agent/jobbergate/schemas.py
+++ b/jobbergate-agent/jobbergate_agent/jobbergate/schemas.py
@@ -1,5 +1,5 @@
 from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import List, Optional
 
 import pydantic
 
@@ -34,7 +34,7 @@ class PendingJobSubmission(pydantic.BaseModel, extra=pydantic.Extra.ignore):
     name: str
     owner_email: str
     execution_directory: Optional[Path]
-    execution_parameters: Dict[str, Any] = pydantic.Field(default_factory=dict)
+    sbatch_arguments: List[str] = pydantic.Field(default_factory=list)
     job_script: JobScript
 
 
diff --git a/jobbergate-agent/jobbergate_agent/jobbergate/submit.py b/jobbergate-agent/jobbergate_agent/jobbergate/submit.py
index daac9cb5..81de9d89 100644
--- a/jobbergate-agent/jobbergate_agent/jobbergate/submit.py
+++ b/jobbergate-agent/jobbergate_agent/jobbergate/submit.py
@@ -1,56 +1,25 @@
+from __future__ import annotations
+
 import asyncio
-import json
+import functools
+import os
+import pwd
 from pathlib import Path
-from typing import Any, Dict, List, cast
+from tempfile import TemporaryDirectory
 
-from buzz import DoExceptParams, handle_errors
+from buzz import DoExceptParams
+from jobbergate_core.tools.sbatch import SubmissionHandler, inject_sbatch_params
 from loguru import logger
 
 from jobbergate_agent.clients.cluster_api import backend_client as jobbergate_api_client
-from jobbergate_agent.clients.slurmrestd import backend_client as slurmrestd_client
-from jobbergate_agent.clients.slurmrestd import inject_token
 from jobbergate_agent.jobbergate.constants import FileType
-from jobbergate_agent.jobbergate.schemas import (
-    JobScriptFile,
-    PendingJobSubmission,
-    SlurmJobParams,
-    SlurmJobSubmission,
-    SlurmSubmitResponse,
-)
+from jobbergate_agent.jobbergate.schemas import JobScriptFile, PendingJobSubmission
 from jobbergate_agent.settings import SETTINGS
-from jobbergate_agent.utils.exception import (
-    JobbergateApiError,
-    JobSubmissionError,
-    SlurmParameterParserError,
-    SlurmrestdError,
-    handle_errors_async,
-)
+from jobbergate_agent.utils.exception import JobbergateApiError, JobSubmissionError, handle_errors_async
 from jobbergate_agent.utils.logging import log_error
 from jobbergate_agent.utils.user_mapper import SlurmUserMapper, manufacture
 
 
-def unpack_error_from_slurm_response(response: SlurmSubmitResponse) -> str:
-    """
-    Unpack the error message from the response of a slurmrestd request.
-    """
-    return "; ".join(e.error for e in response.errors if e.error)
-
-
-def get_job_parameters(slurm_parameters: Dict[str, Any], **kwargs) -> SlurmJobParams:
-    """
-    Obtain the job parameters from the slurm_parameters dict and additional values.
-
-    Extra keyword arguments can be used to supply default values for any parameter
-    (like name or current_working_directory). Note they may be overwritten by
-    values from slurm_parameters.
-    """
-    merged_parameters = {**kwargs, **slurm_parameters}
-    if SETTINGS.SLURM_RESTD_VERSION == "v0.0.39":
-        # add required environment variable for slurmrestd v0.0.39
-        merged_parameters.setdefault("environment", []).append("FOO=bar")
-    return SlurmJobParams.parse_obj(merged_parameters)
-
-
 async def retrieve_submission_file(file: JobScriptFile) -> str:
     """
     Get a submission file from the backend and return the decoded file content.
@@ -61,18 +30,19 @@ async def retrieve_submission_file(file: JobScriptFile) -> str:
     return response.content.decode("utf-8")
 
 
-async def write_submission_file(file_content: str, filename: str, submit_dir: Path):
+async def write_submission_file(file_content: str, filename: str, submit_dir: Path) -> Path:
     """
     Write a decoded file content to the submit_dir.
     """
-    local_script_path = submit_dir / filename
-    local_script_path.parent.mkdir(parents=True, exist_ok=True)
-    local_script_path.write_bytes(file_content.encode("utf-8"))
+    safe_filename = Path(filename).name
+    local_script_path = submit_dir / safe_filename
+    local_script_path.write_text(file_content, encoding="utf-8")
 
     logger.debug(f"Copied file to {local_script_path}")
+    return local_script_path
 
 
-async def process_supporting_files(pending_job_submission: PendingJobSubmission, submit_dir: Path):
+async def process_supporting_files(pending_job_submission: PendingJobSubmission, submit_dir: Path) -> list[Path]:
     """
     Process the submission support files.
 
@@ -81,38 +51,32 @@ async def process_supporting_files(pending_job_submission: PendingJobSubmission,
     """
     supporting_files = [file for file in pending_job_submission.job_script.files if file.file_type == FileType.SUPPORT]
 
-    if SETTINGS.WRITE_SUBMISSION_FILES:
-        # Write the supporting submission support files to the submit dir
-        logger.debug(f"Writing supporting submission files to {submit_dir}")
-
-        # Retrieve the files from the backend
-        files_to_retrieve = [retrieve_submission_file(file) for file in supporting_files]
-        files_content = await asyncio.gather(*files_to_retrieve)
-
-        # Write the files to the submit dir
-        files_to_write = [
-            write_submission_file(file_content, file.filename, submit_dir)
-            for file_content, file in zip(files_content, supporting_files)
-        ]
-        await asyncio.gather(*files_to_write)
-    else:
-        # Reject the submission if there are supporting files with WRITE_SUBMISSION_FILES set to False
+    if not supporting_files:
+        return []
+    elif supporting_files and not SETTINGS.WRITE_SUBMISSION_FILES:
         logger.debug(f"Can't write files for submission {pending_job_submission.id}")
-
-        JobSubmissionError.require_condition(
-            not supporting_files,
+        raise JobSubmissionError(
             "Job submission rejected. The submission has supporting files that can't be downloaded to "
             "the execution dir. Set `WRITE_SUBMISSION_FILES` setting to `True` to download the "
             "job script files to the execution dir.",
         )
+    # Write the supporting submission support files to the submit dir
+    logger.debug(f"Writing supporting submission files to {submit_dir}")
 
+    # Retrieve the files from the backend
+    files_to_retrieve = [retrieve_submission_file(file) for file in supporting_files]
+    files_content = await asyncio.gather(*files_to_retrieve)
 
-async def get_job_script_file(pending_job_submission: PendingJobSubmission, submit_dir: Path) -> str:
-    """
-    Get the job script file from the backend.
+    # Write the files to the submit dir
+    files_to_write = [
+        write_submission_file(file_content, file.filename, submit_dir)
+        for file_content, file in zip(files_content, supporting_files)
+    ]
+    return await asyncio.gather(*files_to_write)
 
-    Write the job script file to the submit_dir if WRITE_SUBMISSION_FILES is set to True.
-    """
+
+async def get_job_script_file(pending_job_submission: PendingJobSubmission, submit_dir: Path) -> Path:
+    """Get the job script file from the backend."""
     job_script_file = None
 
     for file in pending_job_submission.job_script.files:
@@ -130,13 +94,15 @@ async def get_job_script_file(pending_job_submission: PendingJobSubmission, subm
 
     job_script = await retrieve_submission_file(job_script_file)
 
-    if SETTINGS.WRITE_SUBMISSION_FILES:
-        await write_submission_file(job_script, job_script_file.filename, submit_dir)
+    if pending_job_submission.sbatch_arguments:
+        job_script = inject_sbatch_params(
+            job_script, pending_job_submission.sbatch_arguments, "Sbatch params injected at submission time"
+        )
 
-    return job_script
+    return await write_submission_file(job_script, job_script_file.filename, submit_dir)
 
 
-async def fetch_pending_submissions() -> List[PendingJobSubmission]:
+async def fetch_pending_submissions() -> list[PendingJobSubmission]:
     """
     Retrieve a list of pending job_submissions.
     """
@@ -192,6 +158,21 @@ async def mark_as_rejected(job_submission_id: int, report_message: str):
         response.raise_for_status()
 
 
+@functools.lru_cache(maxsize=64)
+def run_as_user(username):
+    """Provide to subprocess.run a way to run a command as the user."""
+    # Get the uid and gid from the username
+    pwan = pwd.getpwnam(username)
+    uid = pwan.pw_uid
+    gid = pwan.pw_gid
+
+    def preexec():  # Function to be run in the child process before the subprocess call
+        os.setgid(gid)
+        os.setuid(uid)
+
+    return preexec
+
+
 async def submit_job_script(
     pending_job_submission: PendingJobSubmission,
     user_mapper: SlurmUserMapper,
@@ -202,6 +183,7 @@ async def submit_job_script(
     :param: pending_job_submission: A job_submission with fields needed to submit.
     :returns: The ``slurm_job_id`` for the submitted job
     """
+    logger.debug(f"Submitting {pending_job_submission}")
 
     async def _reject_handler(params: DoExceptParams):
         """
@@ -218,61 +200,50 @@ async def _reject_handler(params: DoExceptParams):
         do_except=_reject_handler,
     ):
         email = pending_job_submission.owner_email
-        name = pending_job_submission.name
         mapper_class_name = user_mapper.__class__.__name__
-        logger.debug(f"Fetching username for email {email} with mapper {mapper_class_name}")
+        logger.debug(f"Fetching username for email '{email}' with mapper {mapper_class_name}")
         username = user_mapper[email]
-        logger.debug(f"Using local slurm user {username} for job submission")
+        logger.debug(f"Using local slurm user '{username}' for job submission")
+        preexec_fn = run_as_user(username)
+
+    submit_dir = pending_job_submission.execution_directory or SETTINGS.DEFAULT_SLURM_WORK_DIR
+    if not submit_dir.exists() or not submit_dir.is_absolute():
+        message = f"The execution directory must exist and be an absolute path, but got '{submit_dir.as_posix()}'"
+        await mark_as_rejected(pending_job_submission.id, message)
+        raise JobSubmissionError(message)
+
+    sbatch_handler = SubmissionHandler(
+        sbatch_path=SETTINGS.SBATCH_PATH,
+        submission_directory=submit_dir,
+        preexec_fn=preexec_fn,
+    )
 
-    async with handle_errors_async(
-        "Error processing job-script files",
-        raise_exc_class=JobSubmissionError,
-        do_except=_reject_handler,
-    ):
-        submit_dir = pending_job_submission.execution_directory or SETTINGS.DEFAULT_SLURM_WORK_DIR
+    with TemporaryDirectory(prefix=str(pending_job_submission.id), suffix=pending_job_submission.name) as tmp_dir:
+        tmp_dir_path = Path(tmp_dir)
+        async with handle_errors_async(
+            "Error processing job-script files",
+            raise_exc_class=JobSubmissionError,
+            do_except=_reject_handler,
+        ):
+            logger.debug(f"Processing submission files for job submission {pending_job_submission.id}")
 
-        logger.debug(f"Processing submission files for job submission {pending_job_submission.id}")
-        await process_supporting_files(pending_job_submission, submit_dir)
+            supporting_files = await process_supporting_files(pending_job_submission, tmp_dir_path)
 
-        logger.debug(f"Fetching job script for job submission {pending_job_submission.id}")
-        job_script = await get_job_script_file(pending_job_submission, submit_dir)
+            logger.debug(f"Fetching job script for job submission {pending_job_submission.id}")
+            job_script = await get_job_script_file(pending_job_submission, tmp_dir_path)
 
-    async with handle_errors_async(
-        "Failed to extract Slurm parameters",
-        raise_exc_class=SlurmParameterParserError,
-        do_except=_reject_handler,
-    ):
-        job_parameters = get_job_parameters(
-            pending_job_submission.execution_parameters,
-            name=name,
-            current_working_directory=submit_dir,
-            standard_output=submit_dir / f"{name}.out",
-            standard_error=submit_dir / f"{name}.err",
-        )
-
-    payload = SlurmJobSubmission(script=job_script, job=job_parameters)
-    logger.debug(f"Submitting pending job submission {pending_job_submission.id} " f"to slurm with payload {payload}")
-
-    response = await slurmrestd_client.post(
-        "/job/submit",
-        auth=lambda r: inject_token(r, username=username),
-        json=json.loads(payload.json()),
-    )
+            if SETTINGS.WRITE_SUBMISSION_FILES:
+                job_script = sbatch_handler.copy_file_to_submission_directory(job_script)
+                for file in supporting_files:
+                    sbatch_handler.copy_file_to_submission_directory(file)
 
     async with handle_errors_async(
         "Failed to submit job to slurm",
-        raise_exc_class=SlurmrestdError,
+        raise_exc_class=JobSubmissionError,
         do_except=_reject_handler,
     ):
-        logger.debug(f"Slurmrestd response: {response.text}")
-        sub_data = SlurmSubmitResponse.parse_raw(response.content)
-        with handle_errors(unpack_error_from_slurm_response(sub_data)):
-            response.raise_for_status()
-
-    # Make static type checkers happy.
-    slurm_job_id = cast(int, sub_data.job_id)
-
-    logger.debug(f"Received slurm job id {slurm_job_id} for job submission {pending_job_submission.id}")
+        logger.debug(f"Submitting job script for job submission {pending_job_submission.id}")
+        slurm_job_id = sbatch_handler.submit_job(job_script)
 
     return slurm_job_id
 
diff --git a/jobbergate-agent/jobbergate_agent/jobbergate/update.py b/jobbergate-agent/jobbergate_agent/jobbergate/update.py
index 6f7d652d..32627c36 100644
--- a/jobbergate-agent/jobbergate_agent/jobbergate/update.py
+++ b/jobbergate-agent/jobbergate_agent/jobbergate/update.py
@@ -1,25 +1,23 @@
 import json
 from typing import List
 
-from httpx import codes
+from jobbergate_core.tools.sbatch import InfoHandler
 from loguru import logger
 
 from jobbergate_agent.clients.cluster_api import backend_client as jobbergate_api_client
-from jobbergate_agent.clients.slurmrestd import backend_client as slurmrestd_client
-
-# from jobbergate_agent.jobbergate.api import update_status
 from jobbergate_agent.jobbergate.schemas import ActiveJobSubmission, SlurmJobData
-from jobbergate_agent.utils.exception import JobbergateApiError, SlurmrestdError
+from jobbergate_agent.settings import SETTINGS
+from jobbergate_agent.utils.exception import JobbergateApiError, SbatchError
 from jobbergate_agent.utils.logging import log_error
 
 
-async def fetch_job_data(slurm_job_id: int) -> SlurmJobData:
+async def fetch_job_data(slurm_job_id: int, info_handler: InfoHandler) -> SlurmJobData:
     logger.debug(f"Fetching slurm job status for slurm job {slurm_job_id}")
 
-    response = await slurmrestd_client.get(f"/job/{slurm_job_id}")
-
-    if response.status_code == codes.NOT_FOUND:
-        logger.warning(f"Couldn't find a slurm job matching id {slurm_job_id}. Reporting job state as UNKNOWN")
+    try:
+        data = info_handler.get_job_info(slurm_job_id)
+    except RuntimeError as e:
+        logger.error(f"Failed to fetch job state from slurm: {e}")
         return SlurmJobData(
             job_id=slurm_job_id,
             job_state="UNKNOWN",
@@ -27,13 +25,9 @@ async def fetch_job_data(slurm_job_id: int) -> SlurmJobData:
             state_reason=f"Slurm did not find a job matching id {slurm_job_id}",
         )
 
-    with SlurmrestdError.handle_errors("Failed to fetch job state from slurm", do_except=log_error):
-        response.raise_for_status()
-        data = response.json()
-        job_info = data["jobs"][0]
-        slurm_state = SlurmJobData.parse_obj(job_info)
-        slurm_state.job_info = json.dumps(job_info)
-        logger.debug(f"State for slurm job {slurm_job_id} is {slurm_state}")
+    with SbatchError.handle_errors("Failed parse info from slurm", do_except=log_error):
+        slurm_state = SlurmJobData.parse_obj(data)
+        slurm_state.job_info = json.dumps(data)
 
     return slurm_state
 
@@ -58,7 +52,7 @@ async def update_job_data(
     """
     Update a job submission with the job state
     """
-    logger.debug(f"Updating {job_submission_id=} with {slurm_job_data=}")
+    logger.debug(f"Updating {job_submission_id=} with: {slurm_job_data}")
 
     with JobbergateApiError.handle_errors(
         f"Could not update job data for job submission {job_submission_id} via the API",
@@ -82,21 +76,21 @@ async def update_active_jobs():
     """
     logger.debug("Started updating slurm job data for active jobs...")
 
+    sbatch_handler = InfoHandler(scontrol_path=SETTINGS.SCONTROL_PATH)
+
     logger.debug("Fetching active jobs")
     active_job_submissions = await fetch_active_submissions()
 
+    skip = "skipping to next active job"
     for active_job_submission in active_job_submissions:
-        skip = "skipping to next active job"
         logger.debug(f"Fetching slurm job state of job_submission {active_job_submission.id}")
 
         try:
-            slurm_job_data: SlurmJobData = await fetch_job_data(active_job_submission.slurm_job_id)
+            slurm_job_data: SlurmJobData = await fetch_job_data(active_job_submission.slurm_job_id, sbatch_handler)
         except Exception:
             logger.debug(f"Fetch job data failed...{skip}")
             continue
 
-        logger.debug(f"Updating job_submission with slurm job data={slurm_job_data}")
-
         try:
             await update_job_data(active_job_submission.id, slurm_job_data)
         except Exception:
diff --git a/jobbergate-agent/jobbergate_agent/settings.py b/jobbergate-agent/jobbergate_agent/settings.py
index 78d94eb3..85be68cb 100644
--- a/jobbergate-agent/jobbergate_agent/settings.py
+++ b/jobbergate-agent/jobbergate_agent/settings.py
@@ -10,23 +10,16 @@
 
 
 class Settings(BaseSettings):
-    # slurmrestd info
-    BASE_SLURMRESTD_URL: AnyHttpUrl = Field("http://127.0.0.1:6820")
-    SLURM_RESTD_VERSION: str = "v0.0.36"
-    SLURM_RESTD_VERSIONED_URL: Optional[AnyHttpUrl] = None
+    # Sbatch
+    SBATCH_PATH: Path = Path("/usr/bin/sbatch")
+    SCONTROL_PATH: Path = Path("/usr/bin/scontrol")
     X_SLURM_USER_NAME: str = "ubuntu"
-    X_SLURM_USER_TOKEN: Optional[str]
     DEFAULT_SLURM_WORK_DIR: Path = Path("/tmp")
 
-    # Slurmrestd authentication
-    SLURMRESTD_JWT_KEY_PATH: Optional[str]
-    SLURMRESTD_JWT_KEY_STRING: Optional[str]
-    SLURMRESTD_USE_KEY_PATH: bool = True
-    SLURMRESTD_EXP_TIME_IN_SECONDS: int = 60 * 60 * 24  # one day
-
     # cluster api info
     BASE_API_URL: AnyHttpUrl = Field("https://armada-k8s.staging.omnivector.solutions")
 
+    # Sentry
     SENTRY_DSN: Optional[AnyHttpUrl] = None
     SENTRY_ENV: str = "local"
 
@@ -58,39 +51,21 @@ def compute_extra_settings(cls, values):
         """
         Compute settings values that are based on other settings values.
         """
-        if values.get("SLURM_RESTD_VERSIONED_URL") is None:
-            values["SLURM_RESTD_VERSIONED_URL"] = "{base}/slurm/{version}".format(
-                base=values["BASE_SLURMRESTD_URL"],
-                version=values["SLURM_RESTD_VERSION"],
-            )
+        buzz.require_condition(
+            values["SBATCH_PATH"].is_absolute(),
+            "SBATCH_PATH must be an absolute path to an existing file",
+            ValueError,
+        )
+        buzz.require_condition(
+            values["SCONTROL_PATH"].is_absolute(),
+            "SCONTROL_PATH must be an absolute path to an existing file",
+            ValueError,
+        )
 
         # If using single user, but don't have the setting, use default slurm user
         if values["SINGLE_USER_SUBMITTER"] is None:
             values["SINGLE_USER_SUBMITTER"] = values["X_SLURM_USER_NAME"]
 
-        buzz.require_condition(
-            any(
-                [
-                    values["SLURMRESTD_JWT_KEY_PATH"],
-                    values["SLURMRESTD_JWT_KEY_STRING"],
-                ]
-            ),
-            "Either SLURMRESTD_JWT_KEY_PATH or SLURMRESTD_JWT_KEY_STRING must be configured",
-            RuntimeError,
-        )
-
-        if all(
-            [
-                values["SLURMRESTD_JWT_KEY_PATH"],
-                values["SLURMRESTD_JWT_KEY_STRING"],
-            ]
-        ):
-            logger.warning(
-                "ALERT! Both SLURMRESTD_JWT_KEY_PATH and SLURMRESTD_JWT_KEY_STRING"
-                " were passed. Prioritizing the SLURMRESTD_JWT_KEY_STRING so."
-            )
-            values["SLURMRESTD_USE_KEY_PATH"] = False
-
         return values
 
     class Config:
diff --git a/jobbergate-agent/jobbergate_agent/utils/exception.py b/jobbergate-agent/jobbergate_agent/utils/exception.py
index a3cd3d44..05b85075 100644
--- a/jobbergate-agent/jobbergate_agent/utils/exception.py
+++ b/jobbergate-agent/jobbergate_agent/utils/exception.py
@@ -1,4 +1,5 @@
 """Core module for exception related operations"""
+
 import contextlib
 from asyncio import iscoroutinefunction
 from typing import Any, Callable, Iterable, Iterator, Mapping, Optional, Tuple, Type, Union
@@ -19,8 +20,8 @@ class AuthTokenError(ClusterAgentError):
     """Raise exception when there are connection issues with the backend"""
 
 
-class SlurmrestdError(ClusterAgentError):
-    """Raise exception when slurmrestd raises any error"""
+class SbatchError(ClusterAgentError):
+    """Raise exception when sbatch raises any error"""
 
 
 class JobbergateApiError(ClusterAgentError):
diff --git a/jobbergate-agent/jobbergate_agent/utils/scheduler.py b/jobbergate-agent/jobbergate_agent/utils/scheduler.py
index 4bf63c7e..1a96a5f4 100644
--- a/jobbergate-agent/jobbergate_agent/utils/scheduler.py
+++ b/jobbergate-agent/jobbergate_agent/utils/scheduler.py
@@ -7,6 +7,7 @@
     https://github.com/agronholm/apscheduler
     https://packaging.python.org/en/latest/guides/creating-and-discovering-plugins
 """
+
 from typing import Protocol, Union
 
 from apscheduler.job import Job
diff --git a/jobbergate-agent/jobbergate_agent/utils/user_mapper.py b/jobbergate-agent/jobbergate_agent/utils/user_mapper.py
index a59ef0d1..9aa4738b 100644
--- a/jobbergate-agent/jobbergate_agent/utils/user_mapper.py
+++ b/jobbergate-agent/jobbergate_agent/utils/user_mapper.py
@@ -3,6 +3,7 @@
 
 Custom mappers can be added to the agent as installable plugins, which are discovered at runtime.
 """
+
 from collections.abc import Mapping as MappingABC
 from dataclasses import dataclass
 from typing import Mapping, Protocol
diff --git a/jobbergate-agent/poetry.lock b/jobbergate-agent/poetry.lock
index 046eb436..3f8e79a7 100644
--- a/jobbergate-agent/poetry.lock
+++ b/jobbergate-agent/poetry.lock
@@ -72,34 +72,6 @@ files = [
     {file = "auto_name_enum-2.0.0-py3-none-any.whl", hash = "sha256:fb9fe25985d7ef890e7b419d242b9ec145162ae1252121f8a5e3e8cca0e6d87a"},
 ]
 
-[[package]]
-name = "backports-zoneinfo"
-version = "0.2.1"
-description = "Backport of the standard library zoneinfo module"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"},
-    {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"},
-]
-
-[package.extras]
-tzdata = ["tzdata"]
-
 [[package]]
 name = "black"
 version = "24.3.0"
@@ -493,43 +465,6 @@ files = [
     {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"},
 ]
 
-[[package]]
-name = "importlib-metadata"
-version = "6.11.0"
-description = "Read metadata from Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "importlib_metadata-6.11.0-py3-none-any.whl", hash = "sha256:f0afba6205ad8f8947c7d338b5342d5db2afbfd82f9cbef7879a9539cc12eb9b"},
-    {file = "importlib_metadata-6.11.0.tar.gz", hash = "sha256:1231cf92d825c9e03cfc4da076a16de6422c863558229ea0b22b675657463443"},
-]
-
-[package.dependencies]
-zipp = ">=0.5"
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
-perf = ["ipython"]
-testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"]
-
-[[package]]
-name = "importlib-resources"
-version = "5.13.0"
-description = "Read resources from Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2"},
-    {file = "importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528"},
-]
-
-[package.dependencies]
-zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
-
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -557,10 +492,10 @@ colors = ["colorama (>=0.4.6)"]
 
 [[package]]
 name = "jobbergate-core"
-version = "4.3.0a5"
+version = "4.4.0"
 description = "Jobbergate Core"
 optional = false
-python-versions = "^3.8"
+python-versions = "^3.10"
 files = []
 develop = true
 
@@ -769,8 +704,6 @@ files = [
 ]
 
 [package.dependencies]
-"backports.zoneinfo" = {version = ">=0.2.1,<0.3.0", markers = "python_version < \"3.9\""}
-importlib-resources = {version = ">=5.9.0,<6.0.0", markers = "python_version < \"3.9\""}
 python-dateutil = ">=2.6,<3.0"
 time-machine = {version = ">=2.6.0,<3.0.0", markers = "implementation_name != \"pypy\""}
 tzdata = ">=2020.1"
@@ -1328,7 +1261,6 @@ files = [
 ]
 
 [package.dependencies]
-"backports.zoneinfo" = {version = "*", markers = "python_version < \"3.9\""}
 tzdata = {version = "*", markers = "platform_system == \"Windows\""}
 
 [package.extras]
@@ -1365,22 +1297,7 @@ files = [
 [package.extras]
 dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
 
-[[package]]
-name = "zipp"
-version = "3.17.0"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
-    {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
-
 [metadata]
 lock-version = "2.0"
-python-versions = "^3.8.1"
-content-hash = "3a034e1785cf6162770d07f0023e8bd661c1b017e56812f8e994feab775f4749"
+python-versions = "^3.10"
+content-hash = "e8ce16026289a97d0817086504ebb0a3bbd9831c1a64397fd46c24eaac4451fb"
diff --git a/jobbergate-agent/pyproject.toml b/jobbergate-agent/pyproject.toml
index 7f423738..937a88ed 100644
--- a/jobbergate-agent/pyproject.toml
+++ b/jobbergate-agent/pyproject.toml
@@ -20,7 +20,7 @@ packages = [{ include = "jobbergate_agent" }]
 "Changelog" = "https://github.com/omnivector-solutions/jobbergate/blob/main/jobbergate-agent/CHANGELOG.rst"
 
 [tool.poetry.dependencies]
-python = "^3.8.1"
+python = "^3.10"
 jobbergate-core = { path = "../jobbergate-core", develop = true }
 apscheduler = "3.10.3"
 httpx = "0.24.1"
@@ -81,7 +81,6 @@ env = [
     "JOBBERGATE_AGENT_OIDC_DOMAIN = auth.com",
     "JOBBERGATE_AGENT_OIDC_CLIENT_ID = DUMMY-TEST-CLIENT-ID",
     "JOBBERGATE_AGENT_OIDC_CLIENT_SECRET = DUMMY-TEST-CLIENT-SECRET",
-    "JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_STRING = DUMMY-JWT-SECRET",
 ]
 
 [tool.coverage.report]
diff --git a/jobbergate-api/CHANGELOG.md b/jobbergate-api/CHANGELOG.md
index 8b1ef75b..751cff79 100644
--- a/jobbergate-api/CHANGELOG.md
+++ b/jobbergate-api/CHANGELOG.md
@@ -5,6 +5,9 @@ This file keeps track of all notable changes to jobbergate-api
 ## Unreleased
 
 - Fixed a bug when an empty string is passed as a value for `execution_directory` on job submissions
+- Modified the interface with slurm from slurmrestd to sbatch command [ASP-4584]
+  - Removed database column `JobSubmission.execution_parameters`
+  - Added database column `JobSubmission.sbatch_arguments` as a list of strings
 
 ## 4.4.0 -- 2024-03-19
 
diff --git a/jobbergate-api/alembic/env.py b/jobbergate-api/alembic/env.py
index 99e2ae95..027749c1 100644
--- a/jobbergate-api/alembic/env.py
+++ b/jobbergate-api/alembic/env.py
@@ -7,6 +7,7 @@
 from jobbergate_api.apps.job_submissions import models  # noqa # must be imported for metadata to work
 from jobbergate_api.apps.models import Base
 from jobbergate_api.apps.job_script_templates import models  # noqa # must be imported for metadata to work
+
 # from jobbergate_api.metadata import (
 #    metadata as jobbergate_api_metadata,
 # )  # noqa # must be imported for metadata to work
diff --git a/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py b/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py
index d000c9cc..7d299fd2 100644
--- a/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py
+++ b/jobbergate-api/alembic/versions/20230801_071440--3bc095975b53_initial_migration_for_jobbergate_4.py
@@ -5,6 +5,7 @@
 Create Date: 2023-08-01 07:14:40.860152
 
 """
+
 import sqlalchemy as sa
 from sqlalchemy.dialects import postgresql
 
@@ -18,7 +19,6 @@
 
 
 def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
     op.create_table(
         "job_script_templates",
         sa.Column("identifier", sa.String(), nullable=True),
@@ -142,11 +142,9 @@ def upgrade():
     op.create_index(op.f("ix_job_submissions_name"), "job_submissions", ["name"], unique=False)
     op.create_index(op.f("ix_job_submissions_owner_email"), "job_submissions", ["owner_email"], unique=False)
     op.create_index(op.f("ix_job_submissions_status"), "job_submissions", ["status"], unique=False)
-    # ### end Alembic commands ###
 
 
 def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
     op.drop_index(op.f("ix_job_submissions_status"), table_name="job_submissions")
     op.drop_index(op.f("ix_job_submissions_owner_email"), table_name="job_submissions")
     op.drop_index(op.f("ix_job_submissions_name"), table_name="job_submissions")
@@ -162,4 +160,3 @@ def downgrade():
     op.drop_index(op.f("ix_job_script_templates_name"), table_name="job_script_templates")
     op.drop_index(op.f("ix_job_script_templates_identifier"), table_name="job_script_templates")
     op.drop_table("job_script_templates")
-    # ### end Alembic commands ###
diff --git a/jobbergate-api/alembic/versions/20230906_181540--b15213c159f1_make_parent_job_script_null_on_delete.py b/jobbergate-api/alembic/versions/20230906_181540--b15213c159f1_make_parent_job_script_null_on_delete.py
index 306874f0..f389fe63 100644
--- a/jobbergate-api/alembic/versions/20230906_181540--b15213c159f1_make_parent_job_script_null_on_delete.py
+++ b/jobbergate-api/alembic/versions/20230906_181540--b15213c159f1_make_parent_job_script_null_on_delete.py
@@ -5,6 +5,7 @@
 Create Date: 2023-09-06 18:15:40.385837
 
 """
+
 import sqlalchemy as sa
 
 from alembic import op
@@ -17,18 +18,14 @@
 
 
 def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
     op.drop_constraint("job_submissions_job_script_id_fkey", "job_submissions", type_="foreignkey")
     op.create_foreign_key(
         None, "job_submissions", "job_scripts", ["job_script_id"], ["id"], ondelete="SET NULL"
     )
-    # ### end Alembic commands ###
 
 
 def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
     op.drop_constraint(None, "job_submissions", type_="foreignkey")
     op.create_foreign_key(
         "job_submissions_job_script_id_fkey", "job_submissions", "job_scripts", ["job_script_id"], ["id"]
     )
-    # ### end Alembic commands ###
diff --git a/jobbergate-api/alembic/versions/20240104_182646--44d41f873c8d_add_cancelled_status.py b/jobbergate-api/alembic/versions/20240104_182646--44d41f873c8d_add_cancelled_status.py
index 7558a797..b6362f47 100644
--- a/jobbergate-api/alembic/versions/20240104_182646--44d41f873c8d_add_cancelled_status.py
+++ b/jobbergate-api/alembic/versions/20240104_182646--44d41f873c8d_add_cancelled_status.py
@@ -5,6 +5,7 @@
 Create Date: 2024-01-04 18:26:46.943872
 
 """
+
 import sqlalchemy as sa
 from sqlalchemy.dialects import postgresql
 from alembic import op
diff --git a/jobbergate-api/alembic/versions/20240115_171110--ba62c0fb9879_add_clone_capability.py b/jobbergate-api/alembic/versions/20240115_171110--ba62c0fb9879_add_clone_capability.py
index 381be662..e18de6df 100644
--- a/jobbergate-api/alembic/versions/20240115_171110--ba62c0fb9879_add_clone_capability.py
+++ b/jobbergate-api/alembic/versions/20240115_171110--ba62c0fb9879_add_clone_capability.py
@@ -5,6 +5,7 @@
 Create Date: 2024-01-15 17:11:10.626366
 
 """
+
 import sqlalchemy as sa
 from sqlalchemy.dialects import postgresql
 
@@ -18,7 +19,6 @@
 
 
 def upgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
     op.add_column("job_script_templates", sa.Column("cloned_from_id", sa.Integer(), nullable=True))
     op.create_foreign_key(
         "clone",
@@ -36,15 +36,12 @@ def upgrade():
     op.create_foreign_key(
         "clone", "job_submissions", "job_submissions", ["cloned_from_id"], ["id"], ondelete="SET NULL"
     )
-    # ### end Alembic commands ###
 
 
 def downgrade():
-    # ### commands auto generated by Alembic - please adjust! ###
     op.drop_constraint("clone", "job_submissions", type_="foreignkey")
     op.drop_column("job_submissions", "cloned_from_id")
     op.drop_constraint("clone", "job_scripts", type_="foreignkey")
     op.drop_column("job_scripts", "cloned_from_id")
     op.drop_constraint("clone", "job_script_templates", type_="foreignkey")
     op.drop_column("job_script_templates", "cloned_from_id")
-    # ### end Alembic commands ###
diff --git a/jobbergate-api/alembic/versions/20240130_105238--ec2d2948fb41_remap_job_submissions_statuses.py b/jobbergate-api/alembic/versions/20240130_105238--ec2d2948fb41_remap_job_submissions_statuses.py
index af3dffa9..7198f313 100644
--- a/jobbergate-api/alembic/versions/20240130_105238--ec2d2948fb41_remap_job_submissions_statuses.py
+++ b/jobbergate-api/alembic/versions/20240130_105238--ec2d2948fb41_remap_job_submissions_statuses.py
@@ -5,6 +5,7 @@
 Create Date: 2024-01-30 10:52:38.671713
 
 """
+
 import sqlalchemy as sa
 from sqlalchemy.dialects import postgresql
 
@@ -74,14 +75,12 @@ def upgrade():
             "REJECTED",
             "DONE",
             "ABORTED",
-
             # -- Temporarily included until after remapping --
             "COMPLETED",
             "FAILED",
             "UNKNOWN",
             "CANCELLED",
             # ----
-
             name="jobsubmissionstatus",
             native_enum=False,
         ),
@@ -92,12 +91,14 @@ def upgrade():
     op.execute("UPDATE job_submissions SET status = 'DONE' WHERE status = 'COMPLETED'")
     op.execute("UPDATE job_submissions SET status = 'ABORTED' WHERE status = 'FAILED'")
     op.execute("UPDATE job_submissions SET status = 'ABORTED' WHERE status = 'UNKNOWN'")
-    op.execute("""
+    op.execute(
+        """
         UPDATE job_submissions
         SET status = 'ABORTED',
             slurm_job_state = 'CANCELLED'
         WHERE status = 'CANCELLED'
-    """)
+    """
+    )
     # ----
 
     op.alter_column(
@@ -148,12 +149,10 @@ def downgrade():
             "UNKNOWN",
             "REJECTED",
             "CANCELLED",
-
             # -- Temporarily included until after remapping --
             "DONE",
             "ABORTED",
             # ----
-
             name="jobsubmissionstatus",
             native_enum=False,
         ),
@@ -161,12 +160,14 @@ def downgrade():
     )
 
     # -- do the remapping --
-    op.execute("""
+    op.execute(
+        """
         UPDATE job_submissions
         SET status = 'CANCELLED'
         WHERE status = 'ABORTED'
         AND slurm_job_state = 'CANCELLED'
-    """)
+    """
+    )
     op.execute("UPDATE job_submissions SET status = 'FAILED' WHERE status = 'ABORTED'")
     op.execute("UPDATE job_submissions SET status = 'COMPLETED' WHERE status = 'DONE'")
     # ----
diff --git a/jobbergate-api/alembic/versions/20240304_103753--64edbf695d69_replace_slurmrestd_by_sbatch.py b/jobbergate-api/alembic/versions/20240304_103753--64edbf695d69_replace_slurmrestd_by_sbatch.py
new file mode 100644
index 00000000..60ffcb0b
--- /dev/null
+++ b/jobbergate-api/alembic/versions/20240304_103753--64edbf695d69_replace_slurmrestd_by_sbatch.py
@@ -0,0 +1,37 @@
+"""Replace slurmrestd by sbatch
+
+Revision ID: 64edbf695d69
+Revises: ec2d2948fb41
+Create Date: 2024-03-04 10:37:53.921645
+
+"""
+
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+from alembic import op
+
+# revision identifiers, used by Alembic.
+revision = "64edbf695d69"
+down_revision = "ec2d2948fb41"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    op.add_column("job_submissions", sa.Column("sbatch_arguments", sa.ARRAY(sa.String()), nullable=True))
+    op.drop_column("job_submissions", "template_vars")
+
+
+def downgrade():
+    op.add_column(
+        "job_submissions",
+        sa.Column(
+            "template_vars",
+            postgresql.JSONB(astext_type=sa.Text()),
+            server_default=sa.text("'{}'::jsonb"),
+            autoincrement=False,
+            nullable=False,
+        ),
+    )
+    op.drop_column("job_submissions", "sbatch_arguments")
diff --git a/jobbergate-api/dev_tools/__init__.py b/jobbergate-api/dev_tools/__init__.py
index a1bc7478..2485d11e 100644
--- a/jobbergate-api/dev_tools/__init__.py
+++ b/jobbergate-api/dev_tools/__init__.py
@@ -1,6 +1,7 @@
 """
 Provide helper commands for local development.
 """
+
 import typer
 
 from dev_tools import db, dev_server, show_env
diff --git a/jobbergate-api/dev_tools/dev_server.py b/jobbergate-api/dev_tools/dev_server.py
index 1a2f66d9..37cdabfe 100644
--- a/jobbergate-api/dev_tools/dev_server.py
+++ b/jobbergate-api/dev_tools/dev_server.py
@@ -1,6 +1,7 @@
 """
 Provide command for starting a local development server for the API.
 """
+
 from time import sleep
 
 import typer
diff --git a/jobbergate-api/dev_tools/show_env.py b/jobbergate-api/dev_tools/show_env.py
index 77c2571f..cf8a17d9 100644
--- a/jobbergate-api/dev_tools/show_env.py
+++ b/jobbergate-api/dev_tools/show_env.py
@@ -1,6 +1,7 @@
 """
 Provide command for showing the current environment.
 """
+
 import json
 
 import typer
@@ -19,8 +20,6 @@ def show_env(use_json: bool = typer.Option(False, "--json", help="Dump as JSON")
         output = json.dumps(settings.dict())
     else:
         output = "\n  ".join(
-            ["Jobbergate settings:"] + [
-                f"{k}: {v}" for (k, v) in settings.dict().items()
-            ],
+            ["Jobbergate settings:"] + [f"{k}: {v}" for (k, v) in settings.dict().items()],
         )
     print(output)
diff --git a/jobbergate-api/jobbergate_api/apps/dependencies.py b/jobbergate-api/jobbergate_api/apps/dependencies.py
index e08077e9..e2aa91f6 100644
--- a/jobbergate-api/jobbergate_api/apps/dependencies.py
+++ b/jobbergate-api/jobbergate_api/apps/dependencies.py
@@ -4,6 +4,7 @@
 Note:
     The dependencies can be reused multiple times, since FastAPI caches the results.
 """
+
 from contextlib import asynccontextmanager, contextmanager
 from dataclasses import dataclass
 from itertools import chain
diff --git a/jobbergate-api/jobbergate_api/apps/file_validation.py b/jobbergate-api/jobbergate_api/apps/file_validation.py
index 8cee5704..96eeb80f 100644
--- a/jobbergate-api/jobbergate_api/apps/file_validation.py
+++ b/jobbergate-api/jobbergate_api/apps/file_validation.py
@@ -1,6 +1,7 @@
 """
 Validation methods for the uploaded files.
 """
+
 from ast import parse as ast_parse
 from functools import wraps
 from pathlib import PurePath
diff --git a/jobbergate-api/jobbergate_api/apps/garbage_collector.py b/jobbergate-api/jobbergate_api/apps/garbage_collector.py
index 59e4db3e..893798a9 100644
--- a/jobbergate-api/jobbergate_api/apps/garbage_collector.py
+++ b/jobbergate-api/jobbergate_api/apps/garbage_collector.py
@@ -1,4 +1,5 @@
 """Delete unused files from jobbergate's file storage."""
+
 from fastapi import BackgroundTasks
 from loguru import logger
 from sqlalchemy import select
diff --git a/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py b/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py
index 8a74920c..2db0905c 100644
--- a/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_script_templates/routers.py
@@ -293,8 +293,7 @@ async def job_script_workflow_get_file(
 )
 async def job_script_workflow_upload_file(
     id_or_identifier: int | str = Path(),
-    runtime_config: RunTimeConfig
-    | None = Body(
+    runtime_config: RunTimeConfig | None = Body(
         None, description="Runtime configuration is optional when the workflow file already exists"
     ),
     upload_file: UploadFile = File(..., description="File to upload"),
diff --git a/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py b/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py
index eb3269bd..71156314 100644
--- a/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py
+++ b/jobbergate-api/jobbergate_api/apps/job_script_templates/services.py
@@ -1,4 +1,5 @@
 """Services for the job_script_templates resource, including module specific business logic."""
+
 from typing import Any
 
 from buzz import require_condition
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
index 55e4ad8d..d136553d 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/routers.py
@@ -1,4 +1,5 @@
 """Router for the Job Script Template resource."""
+
 from typing import cast
 
 from buzz import handle_errors
@@ -202,8 +203,7 @@ async def job_script_get(
 )
 async def job_script_get_list(
     list_params: ListParams = Depends(),
-    from_job_script_template_id: int
-    | None = Query(
+    from_job_script_template_id: int | None = Query(
         None,
         description="Filter job-scripts by the job-script-template-id they were created from.",
     ),
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
index 49ea3613..d6cb648e 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/schemas.py
@@ -1,6 +1,7 @@
 """
 JobScript resource schema.
 """
+
 from datetime import datetime
 from textwrap import dedent
 from typing import Any
diff --git a/jobbergate-api/jobbergate_api/apps/job_scripts/services.py b/jobbergate-api/jobbergate_api/apps/job_scripts/services.py
index bca2468b..6acb748a 100644
--- a/jobbergate-api/jobbergate_api/apps/job_scripts/services.py
+++ b/jobbergate-api/jobbergate_api/apps/job_scripts/services.py
@@ -1,4 +1,5 @@
 """Services for the job_scripts resource, including module specific business logic."""
+
 from typing import Any, NamedTuple
 
 from buzz import enforce_defined, require_condition
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/models.py b/jobbergate-api/jobbergate_api/apps/job_submissions/models.py
index 08ce3230..209ebe4a 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/models.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/models.py
@@ -4,10 +4,7 @@
 
 from __future__ import annotations
 
-from typing import Any
-
-from sqlalchemy import Enum, ForeignKey, Integer, String, text
-from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy import ARRAY, Enum, ForeignKey, Integer, String
 from sqlalchemy.orm import Mapped, mapped_column, relationship, selectinload
 from sqlalchemy.sql.expression import Select
 
@@ -34,7 +31,7 @@ class JobSubmission(CrudMixin, Base):
         client_id: The id of the custer this submission runs on.
         status: The status of the job submission.
         report_message: The message returned by the job.
-        execution_parameters: The properties of the job.
+        sbatch_arguments: The arguments used to submit the job to the slurm queue.
 
     See Mixin class definitions for other columns
     """
@@ -60,14 +57,7 @@ class JobSubmission(CrudMixin, Base):
         index=True,
     )
     report_message: Mapped[str] = mapped_column(String, nullable=True)
-
-    execution_parameters: Mapped[dict[str, Any]] = mapped_column(
-        "template_vars",
-        JSONB,
-        nullable=False,
-        default=text("'{}'::jsonb"),
-        server_default=text("'{}'::jsonb"),
-    )
+    sbatch_arguments: Mapped[list[str]] = mapped_column(ARRAY(String), nullable=True)
 
     job_script: Mapped[JobScript] = relationship(
         "JobScript",
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/properties_parser.py b/jobbergate-api/jobbergate_api/apps/job_submissions/properties_parser.py
deleted file mode 100644
index d2c11b87..00000000
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/properties_parser.py
+++ /dev/null
@@ -1,305 +0,0 @@
-"""
-Parser for Slurm REST API parameters from SBATCH parameters at the job script file.
-"""
-from argparse import ArgumentParser
-from dataclasses import dataclass, field
-from itertools import chain
-from shlex import split
-from typing import Any, Dict, List, Union
-
-from bidict import bidict
-from loguru import logger
-
-from jobbergate_api.apps.job_submissions.schemas import JobProperties
-
-_IDENTIFICATION_FLAG = "#SBATCH"
-_INLINE_COMMENT_MARK = "#"
-
-
-def _flagged_line(line: str) -> bool:
-    """
-    Identify if a provided line starts with the identification flag.
-    """
-    return line.startswith(_IDENTIFICATION_FLAG)
-
-
-def _clean_line(line: str) -> str:
-    """
-    Clean the provided line.
-
-    It includes removing the identification flag at the beginning of the line,
-    then remove the inline comment mark and anything after it, and finally strip white spaces at both sides.
-    """
-    return line.lstrip(_IDENTIFICATION_FLAG).split(_INLINE_COMMENT_MARK)[0].strip()
-
-
-def _clean_jobscript(jobscript: str) -> List[str]:
-    """
-    Transform a job script string.
-
-    It is done by filtering only the lines that start with
-    the identification flag and mapping a cleaning procedure to them in order
-    to remove the identification flag, remove inline comments, and strip extra
-    white spaces. Finally, split each pair of parameter/value and chain them
-    in a single list.
-    """
-    jobscript_filtered = filter(_flagged_line, jobscript.splitlines())
-    jobscript_cleaned = map(_clean_line, jobscript_filtered)
-    jobscript_splitted = map(split, jobscript_cleaned)
-    return list(chain.from_iterable(jobscript_splitted))
-
-
-@dataclass(frozen=True)
-class SbatchToSlurm:
-    """
-    Store the information for each parameter, including its name at Slurm API and SBATCH.
-
-    Besides that, any extra argument this parameter needs when added to
-    the parser. This information is used to build the jobscript/SBATCH parser
-    and the two-way mapping between Slurm API and SBATCH names.
-    """
-
-    slurmrestd_var_name: str
-    sbatch: str
-    sbatch_short: str = ""
-    argparser_param: dict = field(default_factory=dict)
-
-
-sbatch_to_slurm_mapping = [
-    SbatchToSlurm("account", "--account", "-A"),
-    SbatchToSlurm("account_gather_frequency", "--acctg-freq"),
-    SbatchToSlurm("array", "--array", "-a"),
-    SbatchToSlurm("batch_features", "--batch"),
-    SbatchToSlurm("burst_buffer", "--bb"),
-    SbatchToSlurm("", "--bbf"),
-    SbatchToSlurm("begin_time", "--begin", "-b"),
-    SbatchToSlurm("current_working_directory", "--chdir", "-D"),
-    SbatchToSlurm("cluster_constraints", "--cluster-constraint"),
-    SbatchToSlurm("", "--clusters", "-M"),
-    SbatchToSlurm("comment", "--comment"),
-    SbatchToSlurm("constraints", "--constraint", "-C"),
-    SbatchToSlurm("container", "--container"),
-    SbatchToSlurm("", "--contiguous", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("core_specification", "--core-spec", "-S", dict(type=int)),
-    SbatchToSlurm("cores_per_socket", "--cores-per-socket", "", dict(type=int)),
-    SbatchToSlurm("cpu_binding", "--cpu-bind"),
-    SbatchToSlurm("cpu_frequency", "--cpu-freq"),
-    SbatchToSlurm("cpus_per_gpu", "--cpus-per-gpu"),
-    SbatchToSlurm("cpus_per_task", "--cpus-per-task", "-c", dict(type=int)),
-    SbatchToSlurm("deadline", "--deadline"),
-    SbatchToSlurm("delay_boot", "--delay-boot", "", dict(type=int)),
-    SbatchToSlurm("dependency", "--dependency", "-d"),
-    SbatchToSlurm("distribution", "--distribution", "-m"),
-    SbatchToSlurm("standard_error", "--error", "-e"),
-    SbatchToSlurm("", "--exclude", "-x"),
-    SbatchToSlurm(
-        "exclusive",
-        "--exclusive",
-        "",
-        dict(
-            type=str,
-            choices={"user", "mcs", "exclusive", "oversubscribe"},
-            nargs="?",
-            const="exclusive",
-        ),
-    ),
-    SbatchToSlurm("", "--export"),
-    SbatchToSlurm("", "--export-file"),
-    SbatchToSlurm("", "--extra-node-info", "-B"),
-    SbatchToSlurm("get_user_environment", "--get-user-env", "", dict(type=int)),
-    SbatchToSlurm("", "--gid"),
-    SbatchToSlurm("gpu_binding", "--gpu-bind"),
-    SbatchToSlurm("gpu_frequency", "--gpu-freq"),
-    SbatchToSlurm("gpus", "--gpus", "-G"),
-    SbatchToSlurm("gpus_per_node", "--gpus-per-node"),
-    SbatchToSlurm("gpus_per_socket", "--gpus-per-socket"),
-    SbatchToSlurm("gpus_per_task", "--gpus-per-task"),
-    SbatchToSlurm("gres", "--gres"),
-    SbatchToSlurm("gres_flags", "--gres-flags"),
-    SbatchToSlurm("", "--hint"),
-    SbatchToSlurm("hold", "--hold", "-H", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--ignore-pbs", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("standard_input", "--input", "-i"),
-    SbatchToSlurm("name", "--job-name", "-J"),
-    SbatchToSlurm(
-        "kill_on_invalid_dependency", "--kill-on-invalid-dep", "", dict(type=bool, nargs="?", const=True)
-    ),
-    SbatchToSlurm("licenses", "--licenses", "-L"),
-    SbatchToSlurm("mail_type", "--mail-type"),
-    SbatchToSlurm("mail_user", "--mail-user"),
-    SbatchToSlurm("mcs_label", "--mcs-label"),
-    SbatchToSlurm("memory_per_node", "--mem"),
-    SbatchToSlurm("memory_binding", "--mem-bind"),
-    SbatchToSlurm("memory_per_cpu", "--mem-per-cpu"),
-    SbatchToSlurm("memory_per_gpu", "--mem-per-gpu"),
-    SbatchToSlurm("minimum_cpus_per_node", "--mincpus", "", dict(type=int)),
-    SbatchToSlurm("", "--network"),
-    SbatchToSlurm("nice", "--nice"),
-    SbatchToSlurm("no_kill", "--no-kill", "-k", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--no-requeue", "", dict(action="store_false", dest="requeue", default=None)),
-    SbatchToSlurm("", "--nodefile", "-F"),
-    SbatchToSlurm("", "--nodelist", "-w"),
-    SbatchToSlurm("nodes", "--nodes", "-N"),
-    SbatchToSlurm("tasks", "--ntasks", "-n", dict(type=int)),
-    SbatchToSlurm("tasks_per_core", "--ntasks-per-core", "", dict(type=int)),
-    SbatchToSlurm("", "--ntasks-per-gpu"),
-    SbatchToSlurm("tasks_per_node", "--ntasks-per-node", "", dict(type=int)),
-    SbatchToSlurm("tasks_per_socket", "--ntasks-per-socket", "", dict(type=int)),
-    SbatchToSlurm("open_mode", "--open-mode"),
-    SbatchToSlurm("standard_output", "--output", "-o"),
-    SbatchToSlurm("", "--overcommit", "-O", dict(action="store_const", const=True)),
-    SbatchToSlurm(
-        "",
-        "--oversubscribe",
-        "-s",
-        dict(action="store_const", const="oversubscribe", dest="exclusive"),
-    ),
-    SbatchToSlurm("", "--parsable", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("partition", "--partition", "-p"),
-    SbatchToSlurm("", "--power"),
-    SbatchToSlurm("priority", "--priority"),
-    SbatchToSlurm("", "--profile"),
-    SbatchToSlurm("", "--propagate"),
-    SbatchToSlurm("qos", "--qos", "-q"),
-    SbatchToSlurm("", "--quiet", "-Q", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--reboot", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("requeue", "--requeue", "", dict(action="store_true", default=None)),
-    SbatchToSlurm("reservation", "--reservation"),
-    SbatchToSlurm("signal", "--signal"),
-    SbatchToSlurm("sockets_per_node", "--sockets-per-node", "", dict(type=int)),
-    SbatchToSlurm("spread_job", "--spread-job", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--switches"),
-    SbatchToSlurm("", "--test-only", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("thread_specification", "--thread-spec", "", dict(type=int)),
-    SbatchToSlurm("threads_per_core", "--threads-per-core", "", dict(type=int)),
-    SbatchToSlurm("time_limit", "--time", "-t"),
-    SbatchToSlurm("time_minimum", "--time-min"),
-    SbatchToSlurm("", "--tmp"),
-    SbatchToSlurm("", "--uid"),
-    SbatchToSlurm("", "--usage", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("minimum_nodes", "--use-min-nodes", "", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--verbose", "-v", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--version", "-V", dict(action="store_const", const=True)),
-    SbatchToSlurm("", "--wait", "-W", dict(action="store_const", const=True)),
-    SbatchToSlurm("wait_all_nodes", "--wait-all-nodes", "", dict(type=int)),
-    SbatchToSlurm("wckey", "--wckey"),
-    SbatchToSlurm("", "--wrap"),
-]
-
-
-class ArgumentParserCustomExit(ArgumentParser):
-    """
-    Custom implementation of the built-in class for argument parsing.
-
-    The sys.exit triggered by the original code is replaced by a ValueError,
-    besides some friendly logging messages.
-    """
-
-    def exit(self, status=0, message=None):
-        """
-        Raise ValueError when parsing invalid parameters or if the type of their values is not correct.
-        """
-        log_message = f"Argparse exit status {status}: {message}"
-        if status:
-            logger.error(log_message)
-        else:
-            logger.info(log_message)
-        raise ValueError(message)
-
-
-def build_parser() -> ArgumentParser:
-    """
-    Build an ArgumentParser to handle all SBATCH parameters declared at sbatch_to_slurm.
-    """
-    parser = ArgumentParserCustomExit()
-    for item in sbatch_to_slurm_mapping:
-        args = (i for i in (item.sbatch_short, item.sbatch) if i)
-        parser.add_argument(*args, **item.argparser_param)
-
-    return parser
-
-
-def build_mapping_sbatch_to_slurm() -> bidict:
-    """
-    Create a mapper to translate in both ways between the names expected by Slurm REST API and SBATCH.
-    """
-    mapping: bidict = bidict()
-
-    for item in sbatch_to_slurm_mapping:
-        if item.slurmrestd_var_name:
-            sbatch_name = item.sbatch.lstrip("-").replace("-", "_")
-            mapping[sbatch_name] = item.slurmrestd_var_name
-
-    return mapping
-
-
-def jobscript_to_dict(jobscript: str) -> Dict[str, Union[str, bool]]:
-    """
-    Extract the SBATCH params from a given job script.
-
-    It returns them in a dictionary for mapping the parameter names to their values.
-
-    Raise ValueError if any of the parameters are unknown to the parser.
-    """
-    parsed_args, unknown_arg = parser.parse_known_args(args=_clean_jobscript(jobscript))
-
-    if unknown_arg:
-        raise ValueError("Unrecognized SBATCH arguments: {}".format(" ".join(unknown_arg)))
-
-    sbatch_params = {key: value for key, value in vars(parsed_args).items() if value is not None}
-
-    logger.debug(f"SBATCH params parsed from job script: {sbatch_params}")
-
-    return sbatch_params
-
-
-def convert_sbatch_to_slurm_api(input: Dict[str, Any]) -> Dict[str, Any]:
-    """
-    Take a dictionary containing key-value pairing of SBATCH parameter name space to Slurm API namespace.
-
-    Notice the values should not be affected.
-
-    Raise KeyError if any of the keys are unknown to the mapper.
-    """
-    mapped = {}
-    unknown_keys = []
-
-    for sbatch_name, value in input.items():
-        try:
-            slurm_name = mapping_sbatch_to_slurm[sbatch_name]
-            mapped[slurm_name] = value
-        except KeyError:
-            unknown_keys.append(sbatch_name)
-
-    if unknown_keys:
-        error_message = "Impossible to convert from SBATCH to Slurm REST API: {}"
-        raise KeyError(error_message.format(", ".join(unknown_keys)))
-
-    logger.debug(f"Slurm API params mapped from SBATCH: {mapped}")
-
-    return mapped
-
-
-def get_job_parameters(jobscript: str) -> Dict[str, Any]:
-    """
-    Parse all SBATCH parameters from a job script, map their names to Slurm API parameters.
-
-    They are returned as a key-value pairing dictionary.
-    """
-    return convert_sbatch_to_slurm_api(jobscript_to_dict(jobscript))
-
-
-def get_job_properties_from_job_script(main_file_content: str, **kwargs) -> JobProperties:
-    """
-    Get the job properties for Slurm REST API from a job script file.
-
-    Extra keyword arguments can be used to overwrite any parameter from the
-    job script, like name or current_working_directory.
-    """
-    slurm_parameters = get_job_parameters(main_file_content)
-    merged_parameters = {**slurm_parameters, **kwargs}
-    return JobProperties.parse_obj(merged_parameters)
-
-
-parser = build_parser()
-mapping_sbatch_to_slurm = build_mapping_sbatch_to_slurm()
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
index 0cdfc895..15395642 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/routers.py
@@ -1,9 +1,9 @@
 """
 Router for the JobSubmission resource.
 """
+
 from typing import Any
 
-from buzz import handle_errors
 from fastapi import APIRouter, Depends, HTTPException, Path, Query
 from fastapi import Response as FastAPIResponse
 from fastapi import status
@@ -13,7 +13,6 @@
 from jobbergate_api.apps.constants import FileType
 from jobbergate_api.apps.dependencies import SecureService, secure_services
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus, slurm_job_state_details
-from jobbergate_api.apps.job_submissions.properties_parser import get_job_properties_from_job_script
 from jobbergate_api.apps.job_submissions.schemas import (
     ActiveJobSubmission,
     JobSubmissionAgentRejectedRequest,
@@ -27,7 +26,6 @@
 )
 from jobbergate_api.apps.permissions import Permissions
 from jobbergate_api.apps.schemas import ListParams
-from jobbergate_api.apps.services import ServiceError
 from jobbergate_api.email_notification import notify_submission_rejected
 from jobbergate_api.rabbitmq_notification import publish_status_change
 
@@ -77,24 +75,8 @@ async def job_submission_create(
         raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
 
     if create_request.slurm_job_id is None:
-        main_file_content = await secure_services.file.job_script.get_file_content(job_script_files[0])
-
-        with handle_errors(
-            "Failed to parse execution parameters from the job script",
-            raise_exc_class=ServiceError,
-            raise_kwargs=dict(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY),
-        ):
-            execution_parameters = get_job_properties_from_job_script(
-                main_file_content.decode(), **create_request.execution_parameters.dict(exclude_unset=True)
-            )
-
-        create_request.execution_parameters = execution_parameters
         submission_status = JobSubmissionStatus.CREATED
     else:
-        if create_request.execution_parameters.dict(exclude_unset=True):
-            message = "Execution parameters are not allowed for on-site job submissions"
-            logger.warning(message)
-            raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=message)
         submission_status = JobSubmissionStatus.SUBMITTED
 
     new_job_submission = await secure_services.crud.job_submission.create(
@@ -126,18 +108,15 @@ async def job_submission_get(
 )
 async def job_submission_get_list(
     list_params: ListParams = Depends(),
-    slurm_job_ids: str
-    | None = Query(
+    slurm_job_ids: str | None = Query(
         None,
         description="Comma-separated list of slurm-job-ids to match active job_submissions",
     ),
-    submit_status: JobSubmissionStatus
-    | None = Query(
+    submit_status: JobSubmissionStatus | None = Query(
         None,
         description="Limit results to those with matching status",
     ),
-    from_job_script_id: int
-    | None = Query(
+    from_job_script_id: int | None = Query(
         None,
         description="Filter job-submissions by the job-script-id they were created from.",
     ),
diff --git a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
index f7429785..8b4e8be4 100644
--- a/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
+++ b/jobbergate-api/jobbergate_api/apps/job_submissions/schemas.py
@@ -2,8 +2,7 @@
 JobSubmission resource schema.
 """
 
-import re
-from typing import Dict, List, Literal, Optional
+from typing import Optional
 
 from pydantic import BaseModel, Extra, Field, NonNegativeInt, validator
 
@@ -97,259 +96,17 @@
             Power=
         """,
     ),
-    execution_parameters=MetaField(
-        description=(
-            "The parameters to be passed to the job submission. "
-            "See more details at: https://slurm.schedmd.com/rest_api.html"
-        ),
-        example={"name": "job-submission-name", "comment": "I am a comment"},
-    ),
     is_archived=MetaField(
         description="Indicates if the job submission has been archived.",
         example=False,
     ),
+    sbatch_arguments=MetaField(
+        description="The arguments used to submit the job to the slurm queue",
+        example=["--exclusive", "--job-name=example-job"],
+    ),
 )
 
 
-class JobProperties(BaseModel, extra=Extra.forbid):
-    """
-    Specialized model for job properties.
-
-    See more details at: https://slurm.schedmd.com/rest_api.html
-    """
-
-    account: Optional[LengthLimitedStr] = Field(
-        description="Charge resources used by this job to specified account.",
-    )
-    account_gather_frequency: Optional[LengthLimitedStr] = Field(
-        description="Define the job accounting and profiling sampling intervals.",
-    )
-    argv: Optional[List[LengthLimitedStr]] = Field(description="Arguments to the script.")
-    array: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Submit a job array, multiple jobs to be executed with identical parameters. "
-            "The indexes specification identifies what array index values should be used."
-        )
-    )
-    batch_features: Optional[LengthLimitedStr] = Field(
-        description="features required for batch script's node"
-    )
-    begin_time: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Submit the batch script to the Slurm controller immediately, like normal, "
-            "but tell the controller to defer the allocation of the job until the specified time."
-        )
-    )
-    burst_buffer: Optional[LengthLimitedStr] = Field(description="Burst buffer specification.")
-    cluster_constraints: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Specifies features that a federated cluster must have to have a sibling job submitted to it."
-        )
-    )
-    comment: Optional[LengthLimitedStr] = Field(description="An arbitrary comment.")
-    constraints: Optional[LengthLimitedStr] = Field(description="node features required by job.")
-    container: Optional[LengthLimitedStr] = Field(description="Absolute path to OCI container bundle.")
-    core_specification: Optional[int] = Field(
-        description=(
-            "Count of specialized threads per node reserved by the job for system "
-            "operations and not used by the application."
-        )
-    )
-    cores_per_socket: Optional[int] = Field(
-        description=(
-            "Restrict node selection to nodes with at least the specified number of cores per socket."
-        )
-    )
-    cpu_binding: Optional[LengthLimitedStr] = Field(description="Cpu binding")
-    cpu_binding_hint: Optional[LengthLimitedStr] = Field(description="Cpu binding hint")
-    cpu_frequency: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Request that job steps initiated by srun commands inside this sbatch "
-            "script be run at some requested frequency if possible, on the CPUs "
-            "selected for the step on the compute node(s)."
-        ),
-    )
-    cpus_per_gpu: Optional[LengthLimitedStr] = Field(
-        description="Number of CPUs requested per allocated GPU."
-    )
-    cpus_per_task: Optional[int] = Field(
-        description=(
-            "Advise the Slurm controller that ensuing job steps will require "
-            "ncpus number of processors per task."
-        ),
-    )
-    current_working_directory: Optional[LengthLimitedStr] = Field(
-        description="Instruct Slurm to connect the batch script's standard output directly to the file name."
-    )
-    deadline: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Remove the job if no ending is possible before this deadline (start > (deadline - time[-min]))."
-        )
-    )
-    delay_boot: Optional[int] = Field(
-        description=(
-            "Do not reboot nodes in order to satisfied this job's feature specification if "
-            "the job has been eligible to run for less than this time period."
-        )
-    )
-    dependency: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Defer the start of this job until the specified dependencies have been satisfied completed."
-        )
-    )
-    distribution: Optional[LengthLimitedStr] = Field(
-        description="Specify alternate distribution methods for remote processes."
-    )
-    environment: Optional[Dict[LengthLimitedStr, LengthLimitedStr]] = Field(
-        description="Dictionary of environment entries."
-    )
-    exclusive: Optional[Literal["user", "mcs", "exclusive", "oversubscribe"]] = Field(
-        description=(
-            "The job allocation can share nodes just other users with the "
-            "'user' option or with the 'mcs' option)."
-        )
-    )
-    get_user_environment: Optional[int] = Field(
-        description="Load new login environment for user on job node.",
-        ge=0,
-        le=1,
-    )
-    gres: Optional[LengthLimitedStr] = Field(
-        description="Specifies a comma delimited list of generic consumable resources."
-    )
-    gres_flags: Optional[Literal["disable-binding", "enforce-binding"]] = Field(
-        description="Specify generic resource task binding options."
-    )
-    gpu_binding: Optional[LengthLimitedStr] = Field(description="Requested binding of tasks to GPU.")
-    gpu_frequency: Optional[LengthLimitedStr] = Field(description="Requested GPU frequency.")
-    gpus: Optional[LengthLimitedStr] = Field(description="GPUs per job.")
-    gpus_per_node: Optional[LengthLimitedStr] = Field(description="GPUs per node.")
-    gpus_per_socket: Optional[LengthLimitedStr] = Field(description="GPUs per socket.")
-    gpus_per_task: Optional[LengthLimitedStr] = Field(description="GPUs per task.")
-    hold: Optional[bool] = Field(
-        description="Specify the job is to be submitted in a held state (priority of zero)."
-    )
-    kill_on_invalid_dependency: Optional[bool] = Field(
-        description="If a job has an invalid dependency, then Slurm is to terminate it."
-    )
-    licenses: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Specification of licenses (or other resources available on all nodes of the cluster) "
-            "which must be allocated to this job."
-        )
-    )
-    mail_type: Optional[LengthLimitedStr] = Field(
-        description="Notify user by email when certain event types occur."
-    )
-    mail_user: Optional[LengthLimitedStr] = Field(
-        description="User to receive email notification of state changes as defined by mail_type."
-    )
-    mcs_label: Optional[LengthLimitedStr] = Field(
-        description="This parameter is a group among the groups of the user."
-    )
-    memory_binding: Optional[LengthLimitedStr] = Field(description="Bind tasks to memory.")
-    memory_per_cpu: Optional[NonNegativeInt] = Field(description="Minimum real memory per cpu (MB).")
-    memory_per_gpu: Optional[NonNegativeInt] = Field(description="Minimum memory required per allocated GPU.")
-    memory_per_node: Optional[LengthLimitedStr] = Field(description="Minimum real memory per node (MB).")
-    minimum_cpus_per_node: Optional[NonNegativeInt] = Field(description="Minimum number of CPUs per node.")
-    minimum_nodes: Optional[bool] = Field(
-        description="If a range of node counts is given, prefer the smaller count."
-    )
-    name: Optional[LengthLimitedStr] = Field(description="Specify a name for the job allocation.")
-    nice: Optional[LengthLimitedStr] = Field(
-        description="Run the job with an adjusted scheduling priority within Slurm."
-    )
-    no_kill: Optional[bool] = Field(
-        description="Do not automatically terminate a job if one of the nodes it has been allocated fails."
-    )
-    nodes: Optional[LengthLimitedStr] = Field(
-        description="Request that a minimum of nodes nodes and a maximum node count.",
-    )
-    open_mode: Optional[Literal["append", "truncate"]] = Field(
-        description="Open the output and error files using append or truncate mode as specified."
-    )
-    partition: Optional[LengthLimitedStr] = Field(
-        description="Request a specific partition for the resource allocation."
-    )
-    priority: Optional[LengthLimitedStr] = Field(description="Request a specific job priority.")
-    qos: Optional[LengthLimitedStr] = Field(description="Request a quality of service for the job.")
-    requeue: Optional[bool] = Field(
-        description="Specifies that the batch job should eligible to being requeue."
-    )
-    reservation: Optional[LengthLimitedStr] = Field(
-        description="Allocate resources for the job from the named reservation."
-    )
-    signal: Optional[LengthLimitedStr] = Field(
-        description="When a job is within sig_time seconds of its end time, send it the signal sig_num.",
-    )
-    sockets_per_node: Optional[int] = Field(
-        description="Restrict node selection to nodes with at least the specified number of sockets."
-    )
-    spread_job: Optional[bool] = Field(
-        description=(
-            "Spread the job allocation over as many nodes as possible and attempt "
-            "to evenly distribute tasks across the allocated nodes."
-        )
-    )
-    standard_error: Optional[LengthLimitedStr] = Field(
-        description="Instruct Slurm to connect the batch script's standard error directly to the file name."
-    )
-    standard_input: Optional[LengthLimitedStr] = Field(
-        description=(
-            "Instruct Slurm to connect the batch script's standard input directly to the file name specified."
-        )
-    )
-    standard_output: Optional[LengthLimitedStr] = Field(
-        description="Instruct Slurm to connect the batch script's standard output directly to the file name."
-    )
-    tasks: Optional[NonNegativeInt] = Field(
-        description=(
-            "Advises the Slurm controller that job steps run within the allocation "
-            "will launch a maximum of number tasks and to provide for sufficient resources."
-        )
-    )
-    tasks_per_core: Optional[int] = Field(description="Request the maximum ntasks be invoked on each core.")
-    tasks_per_node: Optional[int] = Field(description="Request the maximum ntasks be invoked on each node.")
-    tasks_per_socket: Optional[int] = Field(
-        description="Request the maximum ntasks be invoked on each socket."
-    )
-    thread_specification: Optional[int] = Field(
-        description=(
-            "Count of specialized threads per node reserved by the job for system "
-            "operations and not used by the application."
-        )
-    )
-    threads_per_core: Optional[NonNegativeInt] = Field(
-        description="Restrict node selection to nodes with at least the specified number of threads per core."
-    )
-    time_limit: Optional[LengthLimitedStr] = Field(description="Step time limit.")
-    time_minimum: Optional[NonNegativeInt] = Field(description="Minimum run time in minutes.")
-    wait_all_nodes: Optional[int] = Field(
-        description="Do not begin execution until all nodes are ready for use.", ge=0, le=1
-    )
-    wckey: Optional[LengthLimitedStr] = Field(description="Specify wckey to be used with job.")
-
-    @validator("signal", pre=True)
-    def _backward_compatibility_on_signal(cls, value):
-        """
-        Remove the prefix B from signal in order to provide compatibility to legacy jobbergate applications.
-
-        The --signal parameter in Slurm commands allows for the specification of a signal to be sent to a job
-        in case of certain events, in order to trigger a specific behavior. The B syntax in sbatch specifies
-        a signal to be sent only to the job's batch shell, but is not supported by srun or the Slurm rest API,
-        since there is no batch shell to be signalized at runtime.
-
-        Notice the use the R option is possible to allow this job to overlap with a reservation with
-        MaxStartDelay set, so it should be preserved on the original value.
-
-        See test_backward_compatibility_on_signal_parameter for usage examples.
-        """
-        if value:
-            value = re.sub(r"^B:", "", value)
-            value = re.sub(r"^BR:|^RB:", r"R:", value)
-        return value
-
-
 class JobSubmissionCreateRequest(BaseModel):
     """
     Request model for creating JobSubmission instances.
@@ -361,7 +118,7 @@ class JobSubmissionCreateRequest(BaseModel):
     slurm_job_id: Optional[NonNegativeInt]
     execution_directory: Optional[LengthLimitedStr]
     client_id: Optional[LengthLimitedStr]
-    execution_parameters: JobProperties = Field(default_factory=JobProperties)
+    sbatch_arguments: Optional[list[LengthLimitedStr]] = Field(None, max_items=50)
 
     @validator("execution_directory", pre=True, always=True)
     def empty_str_to_none(cls, v):
@@ -415,8 +172,8 @@ class JobSubmissionDetailedView(JobSubmissionListView):
 
     execution_directory: Optional[str]
     report_message: Optional[str]
-    execution_parameters: Optional[JobProperties]
     slurm_job_info: Optional[str]
+    sbatch_arguments: Optional[list[str]]
 
 
 class PendingJobSubmission(BaseModel):
@@ -432,6 +189,7 @@ class PendingJobSubmission(BaseModel):
     execution_directory: Optional[str]
     execution_parameters: dict = Field(default_factory=dict)
     job_script: JobScriptDetailedView
+    sbatch_arguments: Optional[list[str]]
 
     class Config:
         orm_mode = True
diff --git a/jobbergate-api/jobbergate_api/config.py b/jobbergate-api/jobbergate_api/config.py
index dd03e936..9eff5505 100644
--- a/jobbergate-api/jobbergate_api/config.py
+++ b/jobbergate-api/jobbergate_api/config.py
@@ -3,6 +3,7 @@
 
 Pull settings from environment variables or a .env file if available.
 """
+
 from enum import Enum
 from typing import Optional
 
diff --git a/jobbergate-api/jobbergate_api/email_notification.py b/jobbergate-api/jobbergate_api/email_notification.py
index cdf69494..f48e01e4 100644
--- a/jobbergate-api/jobbergate_api/email_notification.py
+++ b/jobbergate-api/jobbergate_api/email_notification.py
@@ -1,6 +1,7 @@
 """
 Email notification system for Jobbergate.
 """
+
 from dataclasses import dataclass
 from typing import List, Optional, Union
 
diff --git a/jobbergate-api/jobbergate_api/main.py b/jobbergate-api/jobbergate_api/main.py
index 1afbd9b5..1c1f9c1f 100644
--- a/jobbergate-api/jobbergate_api/main.py
+++ b/jobbergate-api/jobbergate_api/main.py
@@ -1,6 +1,7 @@
 """
 Main file to startup the fastapi server.
 """
+
 from contextlib import asynccontextmanager
 
 import asyncpg
diff --git a/jobbergate-api/jobbergate_api/security.py b/jobbergate-api/jobbergate_api/security.py
index 10909fff..b8f7c4d9 100644
--- a/jobbergate-api/jobbergate_api/security.py
+++ b/jobbergate-api/jobbergate_api/security.py
@@ -3,6 +3,7 @@
 
 Also provides a factory function for TokenSecurity to reduce boilerplate.
 """
+
 from armasec import Armasec, TokenPayload
 from armasec.schemas import DomainConfig
 from armasec.token_security import PermissionMode
diff --git a/jobbergate-api/pyproject.toml b/jobbergate-api/pyproject.toml
index fd656756..c9795b35 100644
--- a/jobbergate-api/pyproject.toml
+++ b/jobbergate-api/pyproject.toml
@@ -80,7 +80,7 @@ dev-tools = "dev_tools:app"
 
 [tool.black]
 line-length = 110
-target-versions = ["py38", "py39", "py310"]
+target-version = ["py38", "py39", "py310"]
 
 [tool.isort]
 line_length = 110
diff --git a/jobbergate-cli/CHANGELOG.md b/jobbergate-cli/CHANGELOG.md
index fdf0631b..85aabf1e 100644
--- a/jobbergate-cli/CHANGELOG.md
+++ b/jobbergate-cli/CHANGELOG.md
@@ -4,7 +4,13 @@ This file keeps track of all notable changes to jobbergate-cli
 
 ## Unreleased
 
-Fixed bug on `jobbergate_cli.subapps.applications.questions.Integer` when a default of zero could not be set properly
+- Fixed bug on `jobbergate_cli.subapps.applications.questions.Integer` when a default of zero could not be set properly
+- Dropped support for Python 3.8 and 3.9
+- Modified the interface with slurm from slurmrestd to sbatch command [ASP-4586]:
+  - Added Jobbergate-core as a project dependency to reuse key components
+  - Refactored on-site submission to use the new `jobbergate-core` components
+  - Replaced `execution_parameters` by `sbatch_arguments` on job submission
+  - Refactored `jobbergate_cli.subapps.job_submissions.tools.create_submission` into two classes in the same module to reduce complexity in the codebase
 
 ## 4.4.0 -- 2024-03-19
 
diff --git a/jobbergate-cli/Dockerfile b/jobbergate-cli/Dockerfile
index b99f0bf8..ff3fd9cf 100644
--- a/jobbergate-cli/Dockerfile
+++ b/jobbergate-cli/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.8-slim-buster
+FROM python:3.10-slim-buster
 
 WORKDIR /app
 
@@ -6,10 +6,14 @@ RUN apt update && apt install -y curl libpq-dev gcc
 
 RUN curl -sSL  https://install.python-poetry.org | \
     POETRY_HOME=/opt/poetry POETRY_VERSION=1.5.1 python && \
-    cd /usr/local/bin && \
-    ln -s /opt/poetry/bin/poetry && \
+    ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry && \
     poetry config virtualenvs.create false
 
 COPY ./pyproject.toml ./poetry.lock* ./README* ./LICENSE* /app/
-COPY ./jobbergate_cli /app/jobbergate_cli
-RUN poetry install --without=dev
+COPY ./etc/entrypoint.sh /app/entrypoint.sh
+WORKDIR /app
+
+VOLUME /app/jobbergate_cli
+VOLUME /jobbergate-core
+
+ENTRYPOINT /app/entrypoint.sh
diff --git a/jobbergate-cli/etc/entrypoint.sh b/jobbergate-cli/etc/entrypoint.sh
new file mode 100755
index 00000000..4fa76576
--- /dev/null
+++ b/jobbergate-cli/etc/entrypoint.sh
@@ -0,0 +1,8 @@
+#!/bin/bash
+
+set -e
+cd /app
+poetry install --without=dev
+
+# Start a bash shell
+exec /bin/bash
\ No newline at end of file
diff --git a/jobbergate-cli/jobbergate_cli/config.py b/jobbergate-cli/jobbergate_cli/config.py
index 17972da1..91043542 100644
--- a/jobbergate-cli/jobbergate_cli/config.py
+++ b/jobbergate-cli/jobbergate_cli/config.py
@@ -96,6 +96,11 @@ def compute_extra_settings(cls, values):
 
         return values
 
+    @property
+    def is_onsite_mode(self) -> bool:
+        """Check if the SBATCH_PATH is set, indicating that the CLI is running in on-site mode."""
+        return self.SBATCH_PATH is not None
+
     class Config:
         """
         Customize behavior of the Settings class. Especially, enable the use of dotenv to load settings from a ``.env``
diff --git a/jobbergate-cli/jobbergate_cli/requests.py b/jobbergate-cli/jobbergate_cli/requests.py
index 6c23e135..5511f2a6 100644
--- a/jobbergate-cli/jobbergate_cli/requests.py
+++ b/jobbergate-cli/jobbergate_cli/requests.py
@@ -1,6 +1,7 @@
 """
 Provide utilities for making requests against the Jobbergate API.
 """
+
 from __future__ import annotations
 
 from pathlib import Path
diff --git a/jobbergate-cli/jobbergate_cli/schemas.py b/jobbergate-cli/jobbergate_cli/schemas.py
index c01ba4a3..66b0208d 100644
--- a/jobbergate-cli/jobbergate_cli/schemas.py
+++ b/jobbergate-cli/jobbergate_cli/schemas.py
@@ -237,7 +237,7 @@ class JobSubmissionResponse(pydantic.BaseModel, extra=pydantic.Extra.ignore):
     created_at: Optional[datetime]
     updated_at: Optional[datetime]
     report_message: Optional[str]
-    execution_parameters: Optional[Dict[str, Any]]
+    sbatch_arguments: Optional[list[str]]
 
 
 class JobScriptCreateRequest(pydantic.BaseModel):
@@ -277,7 +277,7 @@ class JobSubmissionCreateRequestData(pydantic.BaseModel):
     slurm_job_id: Optional[int] = None
     client_id: Optional[str] = pydantic.Field(None, alias="cluster_name")
     execution_directory: Optional[Path] = None
-    execution_parameters: Dict[str, Any] = pydantic.Field(default_factory=dict)
+    sbatch_arguments: Optional[list[str]]
 
 
 EnvelopeT = TypeVar("EnvelopeT")
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
index 39d8a3b7..f11231d1 100644
--- a/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_scripts/app.py
@@ -23,7 +23,7 @@
     upload_job_script_files,
 )
 from jobbergate_cli.subapps.job_submissions.app import HIDDEN_FIELDS as JOB_SUBMISSION_HIDDEN_FIELDS
-from jobbergate_cli.subapps.job_submissions.tools import create_job_submission
+from jobbergate_cli.subapps.job_submissions.tools import job_submissions_factory
 from jobbergate_cli.subapps.pagination import handle_pagination
 from jobbergate_cli.text_tools import dedent
 
@@ -344,7 +344,7 @@ def render(
         actual_value=submit,
     )
 
-    if settings.SBATCH_PATH is None or not submit:
+    if settings.is_onsite_mode is False or not submit:
         # Notice on-site submission will download the job script files anyway, so it is asked just in remote mode.
         download = question_helper(
             question_func=typer.confirm,
@@ -361,23 +361,24 @@ def render(
         return
 
     try:
-        job_submission_result = create_job_submission(
+        submissions_handler = job_submissions_factory(
             jg_ctx=jg_ctx,
             job_script_id=job_script_result.job_script_id,
             name=job_script_result.name,
             description=job_script_result.description,
             cluster_name=cluster_name,
             execution_directory=execution_directory,
-            execution_parameters_file=None,
+            sbatch_arguments=None,
         )
+        job_submission_result = submissions_handler.run()
     except Exception as err:
         raise Abort(
             "Failed to immediately submit the job after job script creation.",
             subject="Automatic job submission failed",
             support=True,
-            log_message=f"""
-                There was an issue submitting the job immediately job_script_id={job_script_result.job_script_id}.
-            """,
+            log_message="There was an issue submitting the job immediately job_script_id={}".format(
+                job_script_result.job_script_id
+            ),
             original_error=err,
         )
 
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py
index 90c04828..d595d5b4 100644
--- a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/app.py
@@ -9,11 +9,11 @@
 import typer
 
 from jobbergate_cli.constants import SortOrder
-from jobbergate_cli.exceptions import handle_abort
+from jobbergate_cli.exceptions import Abort, handle_abort
 from jobbergate_cli.render import StyleMapper, render_single_result, terminal_message
 from jobbergate_cli.requests import make_request
 from jobbergate_cli.schemas import JobbergateContext, JobSubmissionResponse
-from jobbergate_cli.subapps.job_submissions.tools import create_job_submission, fetch_job_submission_data
+from jobbergate_cli.subapps.job_submissions.tools import fetch_job_submission_data, job_submissions_factory
 from jobbergate_cli.subapps.pagination import handle_pagination
 
 
@@ -21,12 +21,12 @@
 HIDDEN_FIELDS = [
     "created_at",
     "execution_directory",
-    "execution_parameters",
     "is_archived",
     "job_script",
     "report_message",
-    "updated_at",
+    "sbatch_arguments",
     "slurm_job_info",
+    "updated_at",
 ]
 
 
@@ -71,17 +71,16 @@ def create(
             """
         ).strip(),
     ),
-    execution_parameters: Optional[Path] = typer.Option(
+    sbatch_arguments: Optional[list[str]] = typer.Option(
         None,
+        "--sbatch-arguments",
+        "-s",
         help=dedent(
             """
-            The path to a JSON file containing the parameters to be passed to the job submission.
-            See more details at: https://slurm.schedmd.com/rest_api.html
+            Additional arguments to pass as sbatch directives. These should be provided as a list of strings.
+            See more details at: https://slurm.schedmd.com/sbatch.html
             """
         ).strip(),
-        exists=True,
-        readable=True,
-        resolve_path=True,
     ),
     download: bool = typer.Option(
         False,
@@ -93,16 +92,26 @@ def create(
     """
     jg_ctx: JobbergateContext = ctx.obj
 
-    result = create_job_submission(
-        jg_ctx,
-        job_script_id,
-        name,
-        description=description,
-        execution_directory=execution_directory,
-        cluster_name=cluster_name,
-        execution_parameters_file=execution_parameters,
-        download=download,
-    )
+    try:
+        submissions_handler = job_submissions_factory(
+            jg_ctx,
+            job_script_id,
+            name,
+            description=description,
+            execution_directory=execution_directory,
+            cluster_name=cluster_name,
+            sbatch_arguments=sbatch_arguments,
+            download=download,
+        )
+        result = submissions_handler.run()
+    except Exception as err:
+        raise Abort(
+            "Failed to create the job submission",
+            subject="Job submission failed",
+            support=True,
+            log_message=f"There was an issue submitting the job from job_script_id={job_script_id}",
+            original_error=err,
+        )
 
     render_single_result(
         jg_ctx,
diff --git a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py
index 380d3028..adcc20f2 100644
--- a/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py
+++ b/jobbergate-cli/jobbergate_cli/subapps/job_submissions/tools.py
@@ -2,11 +2,13 @@
 Provide tool functions for working with Job Submission data
 """
 
-import re
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
 from pathlib import Path
-from subprocess import PIPE, Popen
 from typing import Optional, cast
 
+from buzz import enforce_defined
+from jobbergate_core.tools.sbatch import SubmissionHandler, inject_sbatch_params
 from loguru import logger
 
 from jobbergate_cli.config import settings
@@ -14,44 +16,7 @@
 from jobbergate_cli.exceptions import Abort
 from jobbergate_cli.requests import make_request
 from jobbergate_cli.schemas import JobbergateContext, JobSubmissionCreateRequestData, JobSubmissionResponse
-from jobbergate_cli.subapps.job_scripts.tools import download_job_script_files, validate_parameter_file
-
-
-def sbatch_run(filename: str, *argv) -> int:
-    """Execute Job Submission using sbatch and returns slurm id."""
-
-    if settings.SBATCH_PATH is None or not settings.SBATCH_PATH.exists():
-        raise Abort(
-            "The path to the sbatch executable is not set or does not exist. "
-            "Please set the SBATCH_PATH environment to allow on-site job submissions.",
-            subject="sbatch error",
-            support=False,
-        )
-
-    cmd = [settings.SBATCH_PATH.as_posix(), filename, *argv]
-    logger.debug(f"Executing: {' '.join(cmd)}")
-    p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
-    output_raw, err_raw = p.communicate(b"sbatch output")
-
-    output = output_raw.decode("utf-8")
-    err = err_raw.decode("utf-8")
-    rc = p.returncode
-
-    logger.debug("Job submission output: {}", output)
-    logger.debug("Job submission return code: {}", rc)
-
-    if rc != 0:
-        logger.error("Job submission error: {}", err)
-        raise Abort(f"Failed to execute submission with error: {err}", subject="sbatch error", support=False)
-
-    match = re.search(r"^Submitted batch job (\d+)", output)
-
-    if match is None:
-        raise Abort(f"Failed to parse slurm job id from {output=}", subject="sbatch error", support=False)
-
-    slurm_job_id = int(match.group(1))
-    logger.info("Job submission successful. Slurm job id: {}", slurm_job_id)
-    return slurm_job_id
+from jobbergate_cli.subapps.job_scripts.tools import download_job_script_files
 
 
 def _map_cluster_name(
@@ -74,18 +39,10 @@ def _map_cluster_name(
     return f"{base_cluster_name}-{jg_ctx.persona.identity_data.organization_id}"
 
 
-def create_job_submission(
-    jg_ctx: JobbergateContext,
-    job_script_id: int,
-    name: str,
-    description: Optional[str] = None,
-    cluster_name: Optional[str] = None,
-    execution_directory: Optional[Path] = None,
-    execution_parameters_file: Optional[Path] = None,
-    download: bool = False,
-) -> JobSubmissionResponse:
+@dataclass
+class JobSubmissionABC(ABC):
     """
-    Create a Job Submission from the given Job Script.
+    A dataclass representing a job submission for Jobbergate.
 
     :param: jg_ctx:                    The JobbergateContext. Used to retrieve the client for requests
                                        and the email of the submitting user
@@ -98,82 +55,179 @@ def create_job_submission(
     :param: execution_directory:       An optional directory where the job should be executed. If provided as a
                                        relative path, it will be constructed as an absolute path relative to
                                        the current working directory.
-    :param: execution_parameters_file: An optional file containing the execution parameters for the job.
-
-    :returns: The Job Submission data returned by the API after creating the new Job Submission
+    :param: download:                  A flag indicating whether the job script files should be downloaded to the.
+    :param: sbatch_arguments:          An optional list of arguments to pass to inject into the job script.
     """
 
-    # Make static type checkers happy
-    assert jg_ctx.client is not None, "jg_ctx.client is uninitialized"
-
-    if cluster_name is None:
-        cluster_name = settings.DEFAULT_CLUSTER_NAME
+    jg_ctx: JobbergateContext
+    job_script_id: int
+    name: str
+    execution_directory: Path | None = None
+    cluster_name: str | None = None
+    download: bool = False
+    description: Optional[str] = None
+    sbatch_arguments: Optional[list[str]] = None
+
+    def __post_init__(self):
+        """Post-init hook to ensure that the cluster_name and execution_directory are set correctly."""
+        if not self.cluster_name:
+            self.cluster_name = settings.DEFAULT_CLUSTER_NAME
+        if self.cluster_name is None:
+            raise ValueError("No cluster name supplied and no default exists. Cannot submit to an unknown cluster!")
+        self.cluster_name = _map_cluster_name(self.jg_ctx, self.cluster_name)
+
+        if self.execution_directory is None:
+            self.execution_directory = Path.cwd()
+        if not self.execution_directory.is_absolute():
+            self.execution_directory = self.execution_directory.resolve()
+
+    @abstractmethod
+    def process_submission(self):
+        """Process the job submission. This method should be overridden by subclasses."""
+        pass
+
+    def get_request_data(self) -> JobSubmissionCreateRequestData:
+        """Return the data to be used in the POST request to the API."""
+        return JobSubmissionCreateRequestData(
+            name=self.name,
+            description=self.description,
+            job_script_id=self.job_script_id,
+            cluster_name=self.cluster_name,
+            execution_directory=self.execution_directory,
+            sbatch_arguments=self.sbatch_arguments,
+        )
 
-    Abort.require_condition(
-        cluster_name is not None,
-        "No cluster name supplied and no default exists. Cannot submit to an unknown cluster!",
-        raise_kwargs=dict(
-            subject="No cluster Name",
-            support=True,
-        ),
-    )
+    def make_post_request(self, job_submission_data: JobSubmissionCreateRequestData) -> JobSubmissionResponse:
+        """Make the POST request to the API to create the job submission."""
+        # Make static type checkers happy
+        assert self.jg_ctx.client is not None, "jg_ctx.client is uninitialized"
+        return cast(
+            JobSubmissionResponse,
+            make_request(
+                self.jg_ctx.client,
+                "/jobbergate/job-submissions",
+                "POST",
+                expected_status=201,
+                abort_message="Couldn't create job submission",
+                support=True,
+                request_model=job_submission_data,
+                response_model_cls=JobSubmissionResponse,
+            ),
+        )
 
-    Abort.require_condition(
-        settings.SBATCH_PATH is None or execution_parameters_file is None,
-        "Execution parameters file is not compatible with on-site job submissions",
-        raise_kwargs=dict(subject="Job Submission Error", support=False),
-    )
+    def run(self) -> JobSubmissionResponse:
+        """Run all required steps to create the job submission."""
+        self.process_submission()
+        job_submission_data = self.get_request_data()
+        return self.make_post_request(job_submission_data)
 
-    if execution_directory is None:
-        execution_directory = Path.cwd()
-    if not execution_directory.is_absolute():
-        execution_directory = execution_directory.resolve()
 
-    job_submission_data = JobSubmissionCreateRequestData(
-        name=name,
-        description=description,
-        job_script_id=job_script_id,
-        cluster_name=_map_cluster_name(jg_ctx, cluster_name),
-        execution_directory=execution_directory,
-    )
-
-    if execution_parameters_file is not None:
-        job_submission_data.execution_parameters = validate_parameter_file(execution_parameters_file)
+class RemoteJobSubmission(JobSubmissionABC):
+    def process_submission(self):
+        """Process the job submission in remote mode."""
+        logger.info("Creating job submission in remote mode")
+        if self.download:
+            download_job_script_files(self.job_script_id, self.jg_ctx, self.execution_directory)
 
-    if download or settings.SBATCH_PATH is not None:
-        job_script_files = download_job_script_files(job_script_id, jg_ctx, Path.cwd())
 
-    if settings.SBATCH_PATH is None:
-        logger.info("Creating job submission in remote mode")
-    else:
+class OnsiteJobSubmission(JobSubmissionABC):
+    def process_submission(self):
+        """Process the job submission in on-site mode."""
         logger.info("Creating job submission in on-site mode")
+        if settings.SBATCH_PATH is None:
+            raise Abort(
+                "SBATCH_PATH most be set for onsite submissions",
+                subject="Configuration Error",
+                support=True,
+            )
+
+        job_script_files = download_job_script_files(self.job_script_id, self.jg_ctx, self.execution_directory)
 
         entrypoint_file = [f for f in job_script_files if f.file_type == FileType.ENTRYPOINT]
 
         Abort.require_condition(
             len(entrypoint_file) == 1,
-            "There should be exactly one entrypoint file in the parent job script",
+            f"There should be exactly one entrypoint file in the parent job script, got {len(entrypoint_file)}",
             raise_kwargs=dict(subject="Job Script Error"),
         )
+        job_script_path = self.execution_directory / entrypoint_file[0].filename
+
+        self.inject_sbatch_params(job_script_path)
+
+        try:
+            sbatch_handler = SubmissionHandler(
+                sbatch_path=settings.SBATCH_PATH,
+                submission_directory=self.execution_directory,
+            )
+            slurm_id = sbatch_handler.submit_job(job_script_path)
+        except Exception as e:
+            raise Abort(
+                "Failed to submit job to Slurm",
+                raise_kwargs=dict(
+                    subject="Slurm Submission Error",
+                    support=True,
+                    log_message=f"On-site submission failed: {e:s}",
+                ),
+            ) from e
+        self.slurm_job_id = slurm_id
+
+    def inject_sbatch_params(self, job_script_path: Path):
+        """Inject sbatch parameters into the job script."""
+        if not self.sbatch_arguments:
+            return
+        job_script_content = job_script_path.read_text()
+        job_script_content = inject_sbatch_params(
+            job_script_content, self.sbatch_arguments, "Injected at submission time by Jobbergate CLI"
+        )
+        job_script_path.write_text(job_script_content)
+
+    def get_request_data(self) -> JobSubmissionCreateRequestData:
+        """Return the data to be used in the POST request to the API."""
+        data = super().get_request_data()
+        data.slurm_job_id = enforce_defined(
+            getattr(self, "slurm_job_id", None),
+            "Slurm job id not found, on-site submission failed",
+            raise_exc_class=Abort,
+            raise_kwargs=dict(
+                subject="Slurm Submission Error",
+                support=True,
+            ),
+        )
+        return data
 
-        filename = entrypoint_file[0].filename
-        slurm_id = sbatch_run(filename, name)
-        job_submission_data.slurm_job_id = slurm_id
 
-    result = cast(
-        JobSubmissionResponse,
-        make_request(
-            jg_ctx.client,
-            "/jobbergate/job-submissions",
-            "POST",
-            expected_status=201,
-            abort_message="Couldn't create job submission",
-            support=True,
-            request_model=job_submission_data,
-            response_model_cls=JobSubmissionResponse,
-        ),
+def job_submissions_factory(
+    jg_ctx: JobbergateContext,
+    job_script_id: int,
+    name: str,
+    execution_directory: Path | None = None,
+    cluster_name: str | None = None,
+    download: bool = False,
+    description: Optional[str] = None,
+    sbatch_arguments: Optional[list[str]] = None,
+) -> JobSubmissionABC:
+    """Job submission factory function. Returns the correct job submission class based on the current mode."""
+    if settings.is_onsite_mode:
+        return OnsiteJobSubmission(
+            jg_ctx=jg_ctx,
+            job_script_id=job_script_id,
+            name=name,
+            execution_directory=execution_directory,
+            cluster_name=cluster_name,
+            download=download,
+            description=description,
+            sbatch_arguments=sbatch_arguments,
+        )
+    return RemoteJobSubmission(
+        jg_ctx=jg_ctx,
+        job_script_id=job_script_id,
+        name=name,
+        execution_directory=execution_directory,
+        cluster_name=cluster_name,
+        download=download,
+        description=description,
+        sbatch_arguments=sbatch_arguments,
     )
-    return result
 
 
 def fetch_job_submission_data(
diff --git a/jobbergate-cli/poetry.lock b/jobbergate-cli/poetry.lock
index 999063e6..85578a80 100644
--- a/jobbergate-cli/poetry.lock
+++ b/jobbergate-cli/poetry.lock
@@ -33,34 +33,6 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphin
 test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
 trio = ["trio (>=0.23)"]
 
-[[package]]
-name = "backports-zoneinfo"
-version = "0.2.1"
-description = "Backport of the standard library zoneinfo module"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"},
-    {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"},
-]
-
-[package.extras]
-tzdata = ["tzdata"]
-
 [[package]]
 name = "black"
 version = "24.3.0"
@@ -518,24 +490,6 @@ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker
 perf = ["ipython"]
 testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
 
-[[package]]
-name = "importlib-resources"
-version = "5.13.0"
-description = "Read resources from Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2"},
-    {file = "importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528"},
-]
-
-[package.dependencies]
-zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
-
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -608,6 +562,27 @@ files = [
 [package.dependencies]
 ansicon = {version = "*", markers = "platform_system == \"Windows\""}
 
+[[package]]
+name = "jobbergate-core"
+version = "4.5.0a2"
+description = "Jobbergate Core"
+optional = false
+python-versions = "^3.10"
+files = []
+develop = true
+
+[package.dependencies]
+httpx = "^0.24.1"
+loguru = "^0.6.0"
+pendulum = "3.0.0b1"
+py-buzz = "^4.0.0"
+pydantic = "^1.10.12"
+python-jose = "^3.3.0"
+
+[package.source]
+type = "directory"
+url = "../jobbergate-core"
+
 [[package]]
 name = "loguru"
 version = "0.6.0"
@@ -875,8 +850,6 @@ files = [
 ]
 
 [package.dependencies]
-"backports.zoneinfo" = {version = ">=0.2.1,<0.3.0", markers = "python_version < \"3.9\""}
-importlib-resources = {version = ">=5.9.0,<6.0.0", markers = "python_version < \"3.9\""}
 python-dateutil = ">=2.6,<3.0"
 time-machine = {version = ">=2.6.0,<3.0.0", markers = "implementation_name != \"pypy\""}
 tzdata = ">=2020.1"
@@ -1675,5 +1648,5 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p
 
 [metadata]
 lock-version = "2.0"
-python-versions = "^3.8.1"
-content-hash = "6c79cbd52efbd7cd3455b4575fececfab7b3bb0fbd5f3e6ad0bbc1d950f00347"
+python-versions = "^3.10"
+content-hash = "885396e7482326630e7080670bc0b178693920eca7441c6f8641f4c899c132b3"
diff --git a/jobbergate-cli/pyproject.toml b/jobbergate-cli/pyproject.toml
index 83643acc..dfa46637 100644
--- a/jobbergate-cli/pyproject.toml
+++ b/jobbergate-cli/pyproject.toml
@@ -20,19 +20,14 @@ classifiers = [
 "Changelog" = "https://github.com/omnivector-solutions/jobbergate/blob/main/jobbergate-cli/CHANGELOG.rst"
 
 [tool.poetry.dependencies]
-python = "^3.8.1"
+python = "^3.10"
+jobbergate-core = { path = "../jobbergate-core", develop = true }
 click = "^8.1.0"
-httpx = "^0.24.1"
 importlib-metadata = "^4.2"
 inquirer = "^3.1.0"
 Jinja2 = "^3.1.2"
-loguru = "^0.6.0"
-pendulum = "3.0.0b1"
-py-buzz = "^4.0.0"
-pydantic = "^1.10.12"
 pyperclip = "^1.8.2"
 python-dotenv = "^1.0.0"
-python-jose = "^3.3.0"
 PyYAML = "6.*"
 rich = "^11.2.0"
 sentry-sdk = "^1.29.2"
@@ -41,6 +36,14 @@ typer = "^0.9.0"
 [tool.poetry.scripts]
 jobbergate = "jobbergate_cli.main:app"
 
+[tool.stickywheel]
+# This will resolve the relative path to the jobbergate-core package at build time
+# and pin its version based on the version in the jobbergate-cli pyproject.toml file.
+# Since it is at build time, there is no need to install the pluging on dev machines,
+# the deployment workflow will take care of it.
+# Reference: https://github.com/python-poetry/poetry/issues/6850#issuecomment-1445477319
+strategy = "exact"
+
 [tool.poetry.group.dev.dependencies]
 black = ">=23,<25"
 flake8-pyproject = "^1.2.3"
diff --git a/jobbergate-composed/Dockerfile-slurm b/jobbergate-composed/Dockerfile-slurm
index 74f617d8..d4c6e663 100644
--- a/jobbergate-composed/Dockerfile-slurm
+++ b/jobbergate-composed/Dockerfile-slurm
@@ -26,7 +26,7 @@ RUN set -ex \
     && echo "deb [signed-by=/usr/share/keyrings/ubuntu-hpc.gpg] https://ppa.launchpadcontent.net/ubuntu-hpc/slurm-wlm-23.02/ubuntu/ jammy main" > /etc/apt/sources.list.d/ubuntu-hpc-slurm.list \
     && gpg --keyserver hkp://keyserver.ubuntu.com:80  --recv-keys AE87121DC3E4A0B0F81FF023F6D0C5D5C6BEB37B \
     && apt update \
-    && apt install slurm-wlm-basic-plugins slurmrestd slurmd slurmdbd slurmctld mysql-client -y \
+    && apt install slurm-wlm-basic-plugins slurmd slurmdbd slurmctld mysql-client -y \
     && rm -rf "${GNUPGHOME}" ubuntu-hpc.pub
 
 
diff --git a/jobbergate-composed/docker-compose.yml b/jobbergate-composed/docker-compose.yml
index ef44fb58..bf8f5a65 100644
--- a/jobbergate-composed/docker-compose.yml
+++ b/jobbergate-composed/docker-compose.yml
@@ -84,6 +84,7 @@ services:
       - jobbergate-net
     volumes:
       - ../jobbergate-cli/jobbergate_cli/:/app/jobbergate_cli/
+      - ../jobbergate-core/:/jobbergate-core
       - ../examples/simple-application/:/simple-example/
       - ../examples/motorbike-application/:/motorbike-example/
       - ./etc/run-motorbike.py:/app/run-motorbike
@@ -98,36 +99,6 @@ services:
       - JOBBERGATE_CACHE_DIR=/cache
       - DEFAULT_CLUSTER_NAME=local-slurm
 
-  jobbergate-agent:
-    build:
-      context: ../jobbergate-agent/
-      dockerfile: Dockerfile.dev
-    networks:
-      - jobbergate-net
-    volumes:
-      - ../jobbergate-agent/jobbergate_agent/:/app/jobbergate_agent
-      - ../jobbergate-core/:/jobbergate-core
-      - jobbergate-agent-cache:/cache/
-      - ./slurm-work-dir:/slurm-work-dir/
-    command: ["jg-run"]
-    environment:
-      - JOBBERGATE_AGENT_BASE_SLURMRESTD_URL=http://slurmrestd:6820
-      - JOBBERGATE_AGENT_X_SLURM_USER_NAME=local-user
-      - JOBBERGATE_AGENT_DEFAULT_SLURM_WORK_DIR=/slurm-work-dir
-      - JOBBERGATE_AGENT_BASE_API_URL=http://jobbergate-api:80
-      - JOBBERGATE_AGENT_OIDC_DOMAIN=keycloak.local:8080/realms/jobbergate-local
-      - JOBBERGATE_AGENT_OIDC_AUDIENCE=https://local.omnivector.solutions
-      - JOBBERGATE_AGENT_OIDC_CLIENT_ID=local-slurm
-      - JOBBERGATE_AGENT_OIDC_CLIENT_SECRET=SVkaJ2f9xeYfOVzQPHXYyiwr12za4xGF
-      - JOBBERGATE_AGENT_OIDC_USE_HTTPS=false
-      - JOBBERGATE_AGENT_CACHE_DIR=/cache
-      - JOBBERGATE_AGENT_SLURMRESTD_JWT_KEY_STRING=${JWT_SECRET:-supersecret}
-      - JOBBERGATE_AGENT_TASK_JOBS_INTERVAL_SECONDS=15
-      - JOBBERGATE_AGENT_SLURM_RESTD_VERSION=v0.0.37
-    depends_on:
-      jobbergate-api:
-        condition: service_healthy
-
   db:
     image: postgres
     restart: always
@@ -254,23 +225,42 @@ services:
 
   slurmctld:
     build:
-      context: .
-      dockerfile: Dockerfile-slurm
+      context: ../jobbergate-agent/
+      dockerfile: Dockerfile.dev
       args:
         - JWT_SECRET=${JWT_SECRET:-supersecret}
-    image: slurm-docker-cluster
     networks:
       - jobbergate-net
     command: ["slurmctld"]
     container_name: slurmctld
     hostname: slurmctld
+    environment:
+      - JOBBERGATE_AGENT_X_SLURM_USER_NAME=local-user
+      - JOBBERGATE_AGENT_DEFAULT_SLURM_WORK_DIR=/slurm-work-dir
+      - JOBBERGATE_AGENT_BASE_API_URL=http://jobbergate-api:80
+      - JOBBERGATE_AGENT_OIDC_DOMAIN=keycloak.local:8080/realms/jobbergate-local
+      - JOBBERGATE_AGENT_OIDC_AUDIENCE=https://local.omnivector.solutions
+      - JOBBERGATE_AGENT_OIDC_CLIENT_ID=local-slurm
+      - JOBBERGATE_AGENT_OIDC_CLIENT_SECRET=SVkaJ2f9xeYfOVzQPHXYyiwr12za4xGF
+      - JOBBERGATE_AGENT_OIDC_USE_HTTPS=false
+      - JOBBERGATE_AGENT_CACHE_DIR=/cache
+      - JOBBERGATE_AGENT_TASK_JOBS_INTERVAL_SECONDS=15
+      - JOBBERGATE_AGENT_SLURM_RESTD_VERSION=v0.0.37
     volumes:
       - etc_munge:/etc/munge
       - var_log_slurm:/var/log/slurm
       - ./slurm-fake-nfs:/nfs
       - ./slurm-work-dir:/slurm-work-dir
+      - ../jobbergate-agent/jobbergate_agent/:/app/jobbergate_agent
+      - ../jobbergate-core/:/jobbergate-core
+      - jobbergate-agent-cache:/cache/
     expose:
       - "6817"
+    depends_on:
+      jobbergate-api:
+        condition: service_healthy
+      slurmdbd:
+        condition: service_started
 
   slurmdbd:
     build:
@@ -290,7 +280,6 @@ services:
     expose:
       - "6819"
     depends_on:
-      - slurmctld
       - mysql
 
   c1:
@@ -335,31 +324,6 @@ services:
     depends_on:
       - slurmctld
 
-  slurmrestd:
-    build:
-      context: .
-      dockerfile: Dockerfile-slurm
-    image: slurm-docker-cluster
-    networks:
-      - jobbergate-net
-    command: ["slurmrestd"]
-    environment:
-      - SLURMRESTD_SECURITY=disable_unshare_files,disable_unshare_sysv,disable_user_check
-      - SLURM_JWT=daemon
-    container_name: slurmrestd
-    hostname: slurmrestd
-    volumes:
-      - etc_munge:/etc/munge
-      - var_log_slurm:/var/log/slurm
-      - ./slurm-fake-nfs:/nfs
-      - ./slurm-work-dir:/slurm-work-dir
-    expose:
-      - "6820"
-    ports:
-      - 6820:6820
-    depends_on:
-      - slurmctld
-
 volumes:
   postgres_data:
   minio_data:
diff --git a/jobbergate-composed/etc/jobbergate-local.json b/jobbergate-composed/etc/jobbergate-local.json
index 5b24cc74..c871e025 100755
--- a/jobbergate-composed/etc/jobbergate-local.json
+++ b/jobbergate-composed/etc/jobbergate-local.json
@@ -7,8 +7,8 @@
   "refreshTokenMaxReuse": 0,
   "accessTokenLifespan": 300,
   "accessTokenLifespanForImplicitFlow": 900,
-  "ssoSessionIdleTimeout": 1800,
-  "ssoSessionMaxLifespan": 36000,
+  "ssoSessionIdleTimeout": 36000,
+  "ssoSessionMaxLifespan": 360000,
   "ssoSessionIdleTimeoutRememberMe": 0,
   "ssoSessionMaxLifespanRememberMe": 0,
   "offlineSessionIdleTimeout": 2592000,
@@ -2473,4 +2473,4 @@
   "clientPolicies": {
     "policies": []
   }
-}
+}
\ No newline at end of file
diff --git a/jobbergate-composed/etc/slurm-entrypoint.sh b/jobbergate-composed/etc/slurm-entrypoint.sh
index 43e1f435..3e6b3667 100755
--- a/jobbergate-composed/etc/slurm-entrypoint.sh
+++ b/jobbergate-composed/etc/slurm-entrypoint.sh
@@ -51,20 +51,4 @@ then
     exec /usr/sbin/slurmd -Dvvv
 fi
 
-if [ "$1" = "slurmrestd" ]
-then
-
-    echo "---> Waiting for slurmctld to become active before starting slurmrestd..."
-
-    until 2>/dev/null >/dev/tcp/slurmctld/6817
-    do
-        echo "-- slurmctld is not available.  Sleeping ..."
-        sleep 2
-    done
-    echo "-- slurmctld is now active ..."
-
-    echo "---> Starting the Slurm Rest API (slurmrestd) ..."
-    exec /usr/sbin/slurmrestd -vvvv -a rest_auth/jwt 0.0.0.0:6820
-fi
-
 exec "$@"
diff --git a/jobbergate-core/CHANGELOG.md b/jobbergate-core/CHANGELOG.md
index 182d39ce..c6eb9101 100644
--- a/jobbergate-core/CHANGELOG.md
+++ b/jobbergate-core/CHANGELOG.md
@@ -4,6 +4,9 @@ This file keeps track of all notable changes to jobbergate-core
 
 ## Unreleased
 
+- Dropped support for Python 3.8 and 3.9
+- Added helpers to interact with Slurm by using sbatch to submit jobs and scontrol que get information about them [ASP-4585]
+
 ## 4.4.0 -- 2024-03-19
 
 - Keep version in sync with the other components.
diff --git a/jobbergate-core/jobbergate_core/__init__.py b/jobbergate-core/jobbergate_core/__init__.py
index 74c86277..67ccc6ab 100644
--- a/jobbergate-core/jobbergate_core/__init__.py
+++ b/jobbergate-core/jobbergate_core/__init__.py
@@ -1,6 +1,7 @@
 """
 Jobbergate-core contains key components that are shared among sub-projects.
 """
+
 from jobbergate_core.auth import AuthenticationError, JobbergateAuthHandler, TokenError
 from jobbergate_core.version import __version__
 
diff --git a/jobbergate-core/jobbergate_core/auth/__init__.py b/jobbergate-core/jobbergate_core/auth/__init__.py
index 7042001a..6d62a250 100644
--- a/jobbergate-core/jobbergate_core/auth/__init__.py
+++ b/jobbergate-core/jobbergate_core/auth/__init__.py
@@ -1,6 +1,7 @@
 """
 Utilities for handling auth in Jobbergate.
 """
+
 from jobbergate_core.auth.exceptions import AuthenticationError, TokenError
 from jobbergate_core.auth.handler import JobbergateAuthHandler
 from jobbergate_core.auth.token import Token, TokenType
diff --git a/jobbergate-core/jobbergate_core/auth/handler.py b/jobbergate-core/jobbergate_core/auth/handler.py
index aee712cd..5199b473 100644
--- a/jobbergate-core/jobbergate_core/auth/handler.py
+++ b/jobbergate-core/jobbergate_core/auth/handler.py
@@ -1,6 +1,7 @@
 """
 Utilities for handling authentication in the Jobbergate system.
 """
+
 import time
 from collections import namedtuple
 from dataclasses import dataclass, field
diff --git a/jobbergate-core/jobbergate_core/auth/token.py b/jobbergate-core/jobbergate_core/auth/token.py
index 999ce9e8..b0923656 100644
--- a/jobbergate-core/jobbergate_core/auth/token.py
+++ b/jobbergate-core/jobbergate_core/auth/token.py
@@ -1,6 +1,7 @@
 """
 Utilities for handling tokens on Jobbergate.
 """
+
 from __future__ import annotations
 
 from dataclasses import dataclass, field, replace
diff --git a/jobbergate-core/jobbergate_core/py.typed b/jobbergate-core/jobbergate_core/py.typed
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-core/jobbergate_core/tools/__init__.py b/jobbergate-core/jobbergate_core/tools/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-core/jobbergate_core/tools/sbatch.py b/jobbergate-core/jobbergate_core/tools/sbatch.py
new file mode 100644
index 00000000..8bb25877
--- /dev/null
+++ b/jobbergate-core/jobbergate_core/tools/sbatch.py
@@ -0,0 +1,159 @@
+from __future__ import annotations
+
+import json
+import re
+import shlex
+import subprocess
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, Callable, ClassVar, Sequence
+
+from buzz import check_expressions
+from loguru import logger
+
+
+def inject_sbatch_params(job_script_data_as_string: str, sbatch_params: list[str], header: str | None = None) -> str:
+    """
+    Injects sbatch parameters into a job script.
+
+    This function takes a job script as a string, a list of sbatch parameters, and an optional header.
+
+    Args:
+        job_script_data_as_string: The job script as a string.
+        sbatch_params: A list of sbatch parameters to be inserted.
+        header: A comment to be inserted before the parameters (i.e., "Injected at runtime by Jobbergate").
+
+    Returns:
+        The job script with the sbatch parameters inserted.
+    """
+    logger.debug("Preparing to inject sbatch params into job script")
+
+    if not sbatch_params:
+        logger.warning("Sbatch param list is empty")
+        return job_script_data_as_string
+
+    # Default to inserting at the end of the file
+    insert_index = len(job_script_data_as_string)
+    # Find the first non-blank, non-comment line
+    if match := re.search(r"^[^#\n]", job_script_data_as_string, re.MULTILINE):
+        insert_index = match.start()
+
+    inner_string = f"# {header}\n" if header else ""
+    for parameter in sbatch_params:
+        inner_string += f"#SBATCH {parameter}\n"
+    inner_string += "\n"
+
+    new_job_script_data_as_string = (
+        job_script_data_as_string[:insert_index] + inner_string + job_script_data_as_string[insert_index:]
+    )
+
+    logger.debug("Done injecting sbatch params into job script")
+    return new_job_script_data_as_string
+
+
+@dataclass(frozen=True)
+class Command:
+    preexec_fn: Callable | None = None
+
+    def run_command(self, cmd: Sequence[str], **kwargs) -> subprocess.CompletedProcess:
+        """Runs a command as the user."""
+        logger.debug("Running command '{}' with kwargs: {}", " ".join(cmd), kwargs)
+        try:
+            result = subprocess.run(cmd, preexec_fn=self.preexec_fn, check=True, shell=False, **kwargs)
+            logger.trace("Command returned code {} with result: {}", result.returncode, result.stdout)
+            return result
+        except subprocess.CalledProcessError as e:
+            message = f"Failed to run command with code {e.returncode}: {e.stderr or e.stdout}"
+            logger.error(message)
+            raise RuntimeError(message) from e
+
+
+@dataclass(frozen=True)
+class InfoHandler(Command):
+    """Get info from jobs on the cluster."""
+
+    scontrol_path: Path = Path("/usr/bin/scontrol")
+
+    def __post_init__(self):
+        with check_expressions("Check paths", raise_exc_class=ValueError) as check:
+            check(self.scontrol_path.is_absolute(), "scontrol_path is not an absolute path")
+            check(self.scontrol_path.exists(), "scontrol_path does not exist")
+
+    def get_job_info(self, slurm_id: int) -> dict[str, Any]:
+        """Gets job info as the user."""
+        command = (
+            self.scontrol_path.as_posix(),
+            "show",
+            "job",
+            shlex.quote(str(slurm_id)),
+            "--json",
+        )
+        completed_process = self.run_command(command, capture_output=True, text=True)
+        data = json.loads(completed_process.stdout)
+        try:
+            job_info = data["jobs"][0]
+            logger.debug(f"Information for {slurm_id=} is: {job_info}")
+            return job_info
+        except KeyError as e:
+            message = f"Failed to parse job info from {completed_process.stdout}"
+            logger.error(message)
+            raise RuntimeError(message) from e
+        except IndexError as e:
+            message = f"Job not fount: {slurm_id}"
+            logger.warning(message)
+            raise RuntimeError(message) from e
+
+
+@dataclass(frozen=True)
+class SubmissionHandler(Command):
+    """Submits sbatch jobs to the cluster."""
+
+    sbatch_path: Path = Path("/usr/bin/sbatch")
+    submission_directory: Path = field(default_factory=Path.cwd)
+
+    sbatch_output_parser: ClassVar[re.Pattern] = re.compile(r"^(?P<id>\d+)(,(?P<cluster_name>.+))?$")
+
+    def __post_init__(self):
+        with check_expressions("Check paths", raise_exc_class=ValueError) as check:
+            check(self.sbatch_path.is_absolute(), "sbatch_path is not an absolute path")
+            check(self.sbatch_path.exists(), "sbatch_path does not exist")
+            check(self.submission_directory.is_absolute(), "submission_directory is not an absolute path")
+            check(self.submission_directory.exists(), "submission_directory does not exist")
+
+    def submit_job(self, job_script_path: Path) -> int:
+        """Runs sbatch as the user to submit a job script and returns the slurm id assigned to it."""
+        command = (
+            self.sbatch_path.as_posix(),
+            "--parsable",
+            job_script_path.as_posix(),
+        )
+
+        completed_process = self.run_command(command, cwd=self.submission_directory, capture_output=True, text=True)
+
+        if match := self.sbatch_output_parser.match(completed_process.stdout):
+            slurm_id = int(match.group("id"))
+            logger.debug(f"Submission succeeded with {slurm_id=}")
+            return slurm_id
+        message = f"Failed to parse slurm job id from {completed_process.stdout}"
+        logger.error(message)
+        raise RuntimeError(message)
+
+    def copy_file_to_submission_directory(self, source_file: Path) -> Path:
+        """Copies the job file to the submission directory as the user."""
+        # Reference: https://stackoverflow.com/a/71589233
+        destination_file = self.submission_directory / source_file.name
+        command = ("tee", destination_file.as_posix())
+        try:
+            with source_file.open("rb") as source:
+                self.run_command(
+                    command,
+                    stdin=source,
+                    stdout=subprocess.DEVNULL,
+                    stderr=subprocess.PIPE,
+                    text=True,
+                )
+        except IOError as e:
+            message = f"Failed to copy file to submission directory: {e}"
+            logger.error(message)
+            raise RuntimeError(message) from e
+        return destination_file
diff --git a/jobbergate-core/jobbergate_core/version.py b/jobbergate-core/jobbergate_core/version.py
index 106d4843..a079b411 100644
--- a/jobbergate-core/jobbergate_core/version.py
+++ b/jobbergate-core/jobbergate_core/version.py
@@ -1,6 +1,7 @@
 """
 Provide the version of the package.
 """
+
 from importlib import metadata
 
 
diff --git a/jobbergate-core/poetry.lock b/jobbergate-core/poetry.lock
index 9ff8dde1..9759f015 100644
--- a/jobbergate-core/poetry.lock
+++ b/jobbergate-core/poetry.lock
@@ -21,34 +21,6 @@ doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)"]
 test = ["anyio[trio]", "coverage[toml] (>=7)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
 trio = ["trio (>=0.22)"]
 
-[[package]]
-name = "backports-zoneinfo"
-version = "0.2.1"
-description = "Backport of the standard library zoneinfo module"
-optional = false
-python-versions = ">=3.6"
-files = [
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-macosx_10_14_x86_64.whl", hash = "sha256:da6013fd84a690242c310d77ddb8441a559e9cb3d3d59ebac9aca1a57b2e18bc"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:89a48c0d158a3cc3f654da4c2de1ceba85263fafb861b98b59040a5086259722"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:1c5742112073a563c81f786e77514969acb58649bcdf6cdf0b4ed31a348d4546"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win32.whl", hash = "sha256:e8236383a20872c0cdf5a62b554b27538db7fa1bbec52429d8d106effbaeca08"},
-    {file = "backports.zoneinfo-0.2.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8439c030a11780786a2002261569bdf362264f605dfa4d65090b64b05c9f79a7"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-macosx_10_14_x86_64.whl", hash = "sha256:f04e857b59d9d1ccc39ce2da1021d196e47234873820cbeaad210724b1ee28ac"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:17746bd546106fa389c51dbea67c8b7c8f0d14b5526a579ca6ccf5ed72c526cf"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:5c144945a7752ca544b4b78c8c41544cdfaf9786f25fe5ffb10e838e19a27570"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win32.whl", hash = "sha256:e55b384612d93be96506932a786bbcde5a2db7a9e6a4bb4bffe8b733f5b9036b"},
-    {file = "backports.zoneinfo-0.2.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a76b38c52400b762e48131494ba26be363491ac4f9a04c1b7e92483d169f6582"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-macosx_10_14_x86_64.whl", hash = "sha256:8961c0f32cd0336fb8e8ead11a1f8cd99ec07145ec2931122faaac1c8f7fd987"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_i686.whl", hash = "sha256:e81b76cace8eda1fca50e345242ba977f9be6ae3945af8d46326d776b4cf78d1"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:7b0a64cda4145548fed9efc10322770f929b944ce5cee6c0dfe0c87bf4c0c8c9"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-win32.whl", hash = "sha256:1b13e654a55cd45672cb54ed12148cd33628f672548f373963b0bff67b217328"},
-    {file = "backports.zoneinfo-0.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:4a0f800587060bf8880f954dbef70de6c11bbe59c673c3d818921f042f9954a6"},
-    {file = "backports.zoneinfo-0.2.1.tar.gz", hash = "sha256:fadbfe37f74051d024037f223b8e001611eac868b5c5b06144ef4d8b799862f2"},
-]
-
-[package.extras]
-tzdata = ["tzdata"]
-
 [[package]]
 name = "black"
 version = "24.3.0"
@@ -425,24 +397,6 @@ files = [
     {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
 ]
 
-[[package]]
-name = "importlib-resources"
-version = "5.13.0"
-description = "Read resources from Python packages"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "importlib_resources-5.13.0-py3-none-any.whl", hash = "sha256:9f7bd0c97b79972a6cce36a366356d16d5e13b09679c11a58f1014bfdf8e64b2"},
-    {file = "importlib_resources-5.13.0.tar.gz", hash = "sha256:82d5c6cca930697dbbd86c93333bb2c2e72861d4789a11c2662b933e5ad2b528"},
-]
-
-[package.dependencies]
-zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""}
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
-
 [[package]]
 name = "iniconfig"
 version = "2.0.0"
@@ -664,8 +618,6 @@ files = [
 ]
 
 [package.dependencies]
-"backports.zoneinfo" = {version = ">=0.2.1,<0.3.0", markers = "python_version < \"3.9\""}
-importlib-resources = {version = ">=5.9.0,<6.0.0", markers = "python_version < \"3.9\""}
 python-dateutil = ">=2.6,<3.0"
 time-machine = {version = ">=2.6.0,<3.0.0", markers = "implementation_name != \"pypy\""}
 tzdata = ">=2020.1"
@@ -1194,22 +1146,7 @@ files = [
 [package.extras]
 dev = ["black (>=19.3b0)", "pytest (>=4.6.2)"]
 
-[[package]]
-name = "zipp"
-version = "3.17.0"
-description = "Backport of pathlib-compatible object wrapper for zip files"
-optional = false
-python-versions = ">=3.8"
-files = [
-    {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"},
-    {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"},
-]
-
-[package.extras]
-docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"]
-testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"]
-
 [metadata]
 lock-version = "2.0"
-python-versions = "^3.8"
-content-hash = "d91bacc03f81f8d43f59dee2b79dd01e83b135b066617fff779d4c5ea8693f42"
+python-versions = "^3.10"
+content-hash = "65b459976ce45dd8d87cb773c8fb991065ddd77f3ea70d41aa9d4e9bec49144f"
diff --git a/jobbergate-core/pyproject.toml b/jobbergate-core/pyproject.toml
index e43dda2e..c575aa64 100644
--- a/jobbergate-core/pyproject.toml
+++ b/jobbergate-core/pyproject.toml
@@ -19,12 +19,12 @@ classifiers = [
 "Changelog" = "https://github.com/omnivector-solutions/jobbergate/blob/main/jobbergate-core/CHANGELOG.rst"
 
 [tool.poetry.dependencies]
-python = "^3.8"
+python = "^3.10"
 httpx = "^0.24.1"
 loguru = "^0.6.0"
 pendulum = "3.0.0b1"
 py-buzz = "^4.0.0"
-pydantic = "^1.8.2"
+pydantic = "^1.10.12"
 python-jose = "^3.3.0"
 
 [tool.poetry.dev-dependencies]
diff --git a/jobbergate-docs/docs/source/tutorial.md b/jobbergate-docs/docs/source/tutorial.md
index 0bd7458d..a83dc26e 100644
--- a/jobbergate-docs/docs/source/tutorial.md
+++ b/jobbergate-docs/docs/source/tutorial.md
@@ -127,7 +127,6 @@ jobbergate-composed-minio-create-bucket-1   "/create-bucket.sh"      minio-creat
 mysql                                       "docker-entrypoint.s…"   mysql                 running             3306/tcp, 33060/tcp
 slurmctld                                   "/usr/local/bin/slur…"   slurmctld             running             6817/tcp
 slurmdbd                                    "/usr/local/bin/slur…"   slurmdbd              running             6819/tcp
-slurmrestd                                  "/usr/local/bin/slur…"   slurmrestd            running             0.0.0.0:6820->6820/tcp
 ```
 
 The `STATUS` for each service should be "running" except for the `minio-create-bucket` and `jobbergate-cli`


Test patch:
diff --git a/.github/workflows/test_on_push.yaml b/.github/workflows/test_on_push.yaml
index 9cdc8f5a..99af49c7 100644
--- a/.github/workflows/test_on_push.yaml
+++ b/.github/workflows/test_on_push.yaml
@@ -18,7 +18,7 @@ jobs:
           poetry-version: 1.5.1
       - uses: actions/setup-python@v4
         with:
-          python-version: "3.8"
+          python-version: "3.10"
           architecture: "x64"
           cache: "poetry"
           cache-dependency-path: |
@@ -28,7 +28,7 @@ jobs:
       - name: "run quality control checks"
         working-directory: jobbergate-core
         run: |
-          poetry env use "3.8"
+          poetry env use "3.10"
           make qa
 
       - name: Upload coverage reports to Codecov
@@ -73,7 +73,7 @@ jobs:
           poetry-version: 1.5.1
       - uses: actions/setup-python@v4
         with:
-          python-version: "3.8"
+          python-version: "3.10"
           architecture: "x64"
           cache: "poetry"
           cache-dependency-path: |
@@ -83,7 +83,7 @@ jobs:
       - name: "run quality control checks"
         working-directory: jobbergate-cli
         run: |
-          poetry env use "3.8"
+          poetry env use "3.10"
           make qa
 
       - name: Upload coverage reports to Codecov
@@ -107,7 +107,7 @@ jobs:
           poetry-version: 1.5.1
       - uses: actions/setup-python@v4
         with:
-          python-version: "3.8"
+          python-version: "3.10"
           architecture: "x64"
           cache: "poetry"
           cache-dependency-path: |
@@ -117,7 +117,7 @@ jobs:
       - name: "run quality control checks"
         working-directory: jobbergate-agent
         run: |
-          poetry env use "3.8"
+          poetry env use "3.10"
           make qa
 
       - name: Upload coverage reports to Codecov
diff --git a/jobbergate-agent/tests/conftest.py b/jobbergate-agent/tests/conftest.py
index 8f934166..a2b151e2 100644
--- a/jobbergate-agent/tests/conftest.py
+++ b/jobbergate-agent/tests/conftest.py
@@ -38,34 +38,6 @@ def mock_cluster_api_cache_dir(tmp_path):
         yield _cache_dir
 
 
-@pytest.fixture(autouse=True)
-def mock_slurmrestd_api_cache_dir(tmp_path):
-    _cache_dir = tmp_path / ".cache/jobbergate-agent/slurmrestd"
-    with mock.patch("jobbergate_agent.clients.slurmrestd.CACHE_DIR", new=_cache_dir):
-        yield _cache_dir
-
-
-@pytest.fixture(autouse=True)
-def slurmrestd_jwt_key_string():
-    yield "DUMMY-JWT-SECRET"
-
-
-@pytest.fixture(autouse=True)
-def slurmrestd_jwt_key_path(tmp_path, slurmrestd_jwt_key_string):
-    _jwt_dir = tmp_path / "jwt.key"
-    _jwt_dir.write_text(slurmrestd_jwt_key_string)
-    with mock.patch.object(SETTINGS, "SLURMRESTD_JWT_KEY_PATH", new=_jwt_dir.as_posix()):
-        yield _jwt_dir
-
-
-@pytest.fixture(autouse=True)
-def mock_slurmrestd_acquire_token(mocker):
-    mocker.patch(
-        "jobbergate_agent.clients.slurmrestd.acquire_token",
-        return_value="default-dummy-token",
-    )
-
-
 @pytest.fixture
 def caplog(caplog):
     """
diff --git a/jobbergate-agent/tests/identity/test_slurmrestd_identity.py b/jobbergate-agent/tests/identity/test_slurmrestd_identity.py
deleted file mode 100644
index 10c00f73..00000000
--- a/jobbergate-agent/tests/identity/test_slurmrestd_identity.py
+++ /dev/null
@@ -1,94 +0,0 @@
-from datetime import datetime, timedelta, timezone
-from unittest import mock
-
-from freezegun import freeze_time
-from jose import jwt
-
-from jobbergate_agent.clients.slurmrestd import SETTINGS, acquire_token
-from jobbergate_agent.utils.logging import logger
-
-
-def test_acquire_token__gets_a_token_from_the_cache(mock_slurmrestd_api_cache_dir):
-    """
-    Verifies that the token is retrieved from the cache if it is found there.
-    """
-    mock_slurmrestd_api_cache_dir.mkdir(parents=True)
-    token_path = mock_slurmrestd_api_cache_dir / "dummy-user.token"
-    one_minute_from_now = int(datetime.now(tz=timezone.utc).timestamp()) + 60
-    created_token = jwt.encode(
-        dict(exp=one_minute_from_now),
-        key="dummy-key",
-        algorithm="HS256",
-    )
-    token_path.write_text(created_token)
-    retrieved_token = acquire_token("dummy-user")
-    assert retrieved_token == created_token
-
-
-@freeze_time("2022-12-01")
-def test_acquire_token__gets_a_token_from_slurm_if_one_is_not_in_the_cache(
-    mock_slurmrestd_api_cache_dir,
-):  # noqa
-    """
-    Verifies that a token is pulled from Slurm if it is not found in the cache.
-    Also checks to make sure the token is cached.
-    """
-    mock_slurmrestd_api_cache_dir.mkdir(parents=True)
-    token_path = mock_slurmrestd_api_cache_dir / "dummy-user.token"
-    assert not token_path.exists()
-
-    username = "dummy-user"
-
-    now = datetime.now()
-    logger.debug(SETTINGS.SLURMRESTD_JWT_KEY_STRING)
-
-    expected_token = jwt.encode(
-        {
-            "exp": int(datetime.timestamp(now + timedelta(seconds=SETTINGS.SLURMRESTD_EXP_TIME_IN_SECONDS))),
-            "iat": int(datetime.timestamp(now)),
-            "sun": username,
-        },
-        SETTINGS.SLURMRESTD_JWT_KEY_STRING,
-        algorithm="HS256",
-    )
-
-    retrieved_token = acquire_token(username)
-    assert retrieved_token == expected_token
-
-    token_path = mock_slurmrestd_api_cache_dir / "dummy-user.token"
-    assert token_path.read_text() == retrieved_token
-
-
-@mock.patch("jobbergate_agent.clients.slurmrestd.datetime")
-def test_acquire_token__uses_key_path_if_supplied(
-    mocked_datetime,
-    slurmrestd_jwt_key_path,
-    slurmrestd_jwt_key_string,
-):  # noqa
-    """
-    Verifies if the `acquire_token` function generates a token based
-    on the key path if supplied in the Settings class.
-    """
-
-    now = datetime.now()
-    username = "dummy-user"
-
-    mocked_datetime.now = mock.Mock()
-    mocked_datetime.now.return_value = now
-    mocked_datetime.timestamp = mock.Mock()
-    mocked_datetime.timestamp.return_value = 123
-
-    expected_token = jwt.encode(
-        {
-            "exp": 123,
-            "iat": 123,
-            "sun": username,
-        },
-        slurmrestd_jwt_key_string,
-        algorithm="HS256",
-    )
-
-    with mock.patch.object(SETTINGS, "SLURMRESTD_USE_KEY_PATH", new=True):
-        with mock.patch.object(SETTINGS, "SLURMRESTD_JWT_KEY_PATH", new=slurmrestd_jwt_key_path):
-            retrieved_token = acquire_token(username)
-    assert retrieved_token == expected_token
diff --git a/jobbergate-agent/tests/jobbergate/conftest.py b/jobbergate-agent/tests/jobbergate/conftest.py
index 0e682dfc..5fcaf5bc 100644
--- a/jobbergate-agent/tests/jobbergate/conftest.py
+++ b/jobbergate-agent/tests/jobbergate/conftest.py
@@ -1,6 +1,7 @@
 """
 Define fixtures for the ``jobbergate`` section.
 """
+
 from textwrap import dedent
 
 import pytest
diff --git a/jobbergate-agent/tests/jobbergate/test_submit.py b/jobbergate-agent/tests/jobbergate/test_submit.py
index 6d33559e..9bdcc60e 100644
--- a/jobbergate-agent/tests/jobbergate/test_submit.py
+++ b/jobbergate-agent/tests/jobbergate/test_submit.py
@@ -2,24 +2,19 @@
 Define tests for the submission functions of the jobbergate section.
 """
 
+import getpass
 import json
 import re
+from pathlib import Path
+from unittest import mock
 
 import httpx
 import pytest
 import respx
-from pydantic import ValidationError
-
-from jobbergate_agent.jobbergate.schemas import (
-    JobScriptFile,
-    PendingJobSubmission,
-    SlurmJobParams,
-    SlurmJobSubmission,
-    SlurmSubmitResponse,
-)
+
+from jobbergate_agent.jobbergate.schemas import JobScriptFile, PendingJobSubmission
 from jobbergate_agent.jobbergate.submit import (
     fetch_pending_submissions,
-    get_job_parameters,
     get_job_script_file,
     mark_as_rejected,
     mark_as_submitted,
@@ -27,11 +22,10 @@
     retrieve_submission_file,
     submit_job_script,
     submit_pending_jobs,
-    unpack_error_from_slurm_response,
     write_submission_file,
 )
 from jobbergate_agent.settings import SETTINGS
-from jobbergate_agent.utils.exception import JobbergateApiError, JobSubmissionError, SlurmrestdError
+from jobbergate_agent.utils.exception import JobbergateApiError, JobSubmissionError
 from jobbergate_agent.utils.user_mapper import SingleUserMapper, SlurmUserMapper, manufacture
 
 
@@ -51,6 +45,11 @@ def __repr__(self):
         return f"regex='{self.pattern}' (flags={self.flags})"
 
 
+@pytest.fixture
+def user_mapper():
+    return SingleUserMapper(getpass.getuser())
+
+
 @pytest.mark.asyncio
 @pytest.mark.usefixtures("mock_access_token")
 @pytest.mark.asyncio
@@ -150,10 +149,11 @@ async def test_process_supporting_files__with_write_submission_files_set_to_true
             ),
         )
         with tweak_settings(WRITE_SUBMISSION_FILES=True):
-            await process_supporting_files(pending_job_submission, submit_dir)
+            supporting_files = await process_supporting_files(pending_job_submission, submit_dir)
 
-        assert (submit_dir / "input.txt").read_text() == "I am a supporting file"
-        assert download_support_route.call_count == 1
+    assert supporting_files == [submit_dir / "input.txt"]
+    assert (submit_dir / "input.txt").read_text() == "I am a supporting file"
+    assert download_support_route.call_count == 1
 
 
 @pytest.mark.asyncio
@@ -218,48 +218,14 @@ async def test_get_job_script_file__success_with_write(tmp_path, dummy_pending_j
             ),
         )
 
-        with tweak_settings(WRITE_SUBMISSION_FILES=True):
-            actual_content = await get_job_script_file(pending_job_submission, submit_dir)
-
-        assert actual_content == "I am a job script"
-        assert (submit_dir / "application.sh").read_text() == "I am a job script"
-        assert download_route.call_count == 1
-        last_request = download_route.calls.last.request
-        assert last_request.url == f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh"
-
-
-@pytest.mark.usefixtures("mock_access_token")
-@pytest.mark.asyncio
-async def test_get_job_script_file__success_without_write(
-    tmp_path,
-    dummy_pending_job_submission_data,
-    tweak_settings,
-):
-    """
-    Test that the ``get_job_script_file()`` function can retrieve a job script file
-    from the backend and return its content.
-    """
-    pending_job_submission = PendingJobSubmission(**dummy_pending_job_submission_data)
-    submit_dir = tmp_path / "submit"
-    submit_dir.mkdir()
-
-    async with respx.mock:
-        download_route = respx.get(f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh")
-        download_route.mock(
-            return_value=httpx.Response(
-                status_code=200,
-                content="I am a job script".encode("utf-8"),
-            ),
-        )
-
-        with tweak_settings(WRITE_SUBMISSION_FILES=False):
-            actual_content = await get_job_script_file(pending_job_submission, submit_dir)
+        file_path = await get_job_script_file(pending_job_submission, submit_dir)
 
-        assert actual_content == "I am a job script"
-        assert not (submit_dir / "application.sh").exists()
-        assert download_route.call_count == 1
-        last_request = download_route.calls.last.request
-        assert last_request.url == f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh"
+    assert file_path == submit_dir / "application.sh"
+    assert file_path.read_text() == "I am a job script"
+    assert (submit_dir / "application.sh").read_text() == "I am a job script"
+    assert download_route.call_count == 1
+    last_request = download_route.calls.last.request
+    assert last_request.url == f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh"
 
 
 @pytest.mark.asyncio
@@ -420,27 +386,56 @@ async def test_mark_as_rejected__raises_JobbergateApiError_if_the_response_is_no
 
 @pytest.mark.asyncio
 @pytest.mark.usefixtures("mock_access_token")
-async def test_submit_job_script__success(dummy_pending_job_submission_data, dummy_template_source):
+async def test_submit_job_script__success_with_files(
+    mocker, dummy_pending_job_submission_data, dummy_template_source, tweak_settings, user_mapper
+):
     """
     Test that the ``submit_job_script()`` successfully submits a job.
 
     Verifies that a PendingJobSubmission instance is submitted via the Slurm REST API
     and that a ``slurm_job_id`` is returned.
     """
-    user_mapper = SingleUserMapper("dummy-user")
-
     pending_job_submission = PendingJobSubmission(**dummy_pending_job_submission_data)
-    name = pending_job_submission.name
+
+    mocked_sbatch = mock.MagicMock()
+    mocked_sbatch.submit_job = lambda *args, **kwargs: 13
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
 
     async with respx.mock:
-        submit_route = respx.post(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/submit")
-        submit_route.mock(
+        download_route = respx.get(f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh")
+        download_route.mock(
             return_value=httpx.Response(
                 status_code=200,
-                json=dict(job_id=13),
-            )
+                content=dummy_template_source.encode("utf-8"),
+            ),
         )
+        with tweak_settings(WRITE_SUBMISSION_FILES=True):
+            slurm_job_id = await submit_job_script(pending_job_submission, user_mapper)
+
+    assert slurm_job_id == 13
+    assert download_route.call_count == 1
+
+    assert mocked_sbatch.copy_file_to_submission_directory.call_count == len(pending_job_submission.job_script.files)
+
+
+@pytest.mark.asyncio
+@pytest.mark.usefixtures("mock_access_token")
+async def test_submit_job_script__success_without_files(
+    mocker, dummy_pending_job_submission_data, dummy_template_source, tweak_settings, user_mapper
+):
+    """
+    Test that the ``submit_job_script()`` successfully submits a job.
+
+    Verifies that a PendingJobSubmission instance is submitted via the Slurm REST API
+    and that a ``slurm_job_id`` is returned.
+    """
+    pending_job_submission = PendingJobSubmission(**dummy_pending_job_submission_data)
+
+    mocked_sbatch = mock.MagicMock()
+    mocked_sbatch.submit_job = lambda *args, **kwargs: 13
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
 
+    async with respx.mock:
         download_route = respx.get(f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh")
         download_route.mock(
             return_value=httpx.Response(
@@ -448,28 +443,13 @@ async def test_submit_job_script__success(dummy_pending_job_submission_data, dum
                 content=dummy_template_source.encode("utf-8"),
             ),
         )
+        with tweak_settings(WRITE_SUBMISSION_FILES=False):
+            slurm_job_id = await submit_job_script(pending_job_submission, user_mapper)
 
-        slurm_job_id = await submit_job_script(pending_job_submission, user_mapper)
-
-        assert slurm_job_id == 13
-        assert submit_route.call_count == 1
-        assert download_route.call_count == 1
-        last_request = submit_route.calls.last.request
-        assert last_request.method == "POST"
-        assert last_request.headers["x-slurm-user-name"] == "dummy-user"
-        assert last_request.headers["x-slurm-user-token"] == "default-dummy-token"
-        actual_response = last_request.content.decode("utf-8")
-        expected_response = SlurmJobSubmission(
-            script=dummy_template_source,
-            job=SlurmJobParams(
-                name=name,
-                current_working_directory=SETTINGS.DEFAULT_SLURM_WORK_DIR,
-                standard_output=SETTINGS.DEFAULT_SLURM_WORK_DIR / f"{name}.out",
-                standard_error=SETTINGS.DEFAULT_SLURM_WORK_DIR / f"{name}.err",
-            ),
-        ).json()
+    assert slurm_job_id == 13
+    assert download_route.call_count == 1
 
-        assert actual_response == expected_response
+    assert mocked_sbatch.copy_file_to_submission_directory.call_count == 0
 
 
 @pytest.mark.asyncio
@@ -479,6 +459,7 @@ async def test_submit_job_script__with_non_default_execution_directory(
     dummy_template_source,
     mocker,
     tmp_path,
+    user_mapper,
 ):
     """
     Test that the ``submit_job_script()`` successfully submits a job with an exec dir.
@@ -487,34 +468,18 @@ async def test_submit_job_script__with_non_default_execution_directory(
     and that a ``slurm_job_id`` is returned. Verifies that the execution_directory is
     taken from the request and submitted to slurm rest api.
     """
-    user_mapper = SingleUserMapper("dummy-user")
-
-    mocker.patch("jobbergate_agent.clients.slurmrestd.acquire_token", return_value="dummy-token")
     exe_path = tmp_path / "exec"
     exe_path.mkdir()
     pending_job_submission = PendingJobSubmission(
         **dummy_pending_job_submission_data,
         execution_directory=exe_path,
     )
-    name = pending_job_submission.name
-
-    job_parameters = get_job_parameters(
-        pending_job_submission.execution_parameters,
-        name=name,
-        current_working_directory=exe_path,
-        standard_output=exe_path / f"{name}.out",
-        standard_error=exe_path / f"{name}.err",
-    )
 
-    async with respx.mock:
-        submit_route = respx.post(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/submit")
-        submit_route.mock(
-            return_value=httpx.Response(
-                status_code=200,
-                json=dict(job_id=13),
-            )
-        )
+    mocked_sbatch = mock.MagicMock()
+    mocked_sbatch.submit_job = lambda *args, **kwargs: 13
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
 
+    async with respx.mock:
         download_route = respx.get(f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh")
         download_route.mock(
             return_value=httpx.Response(
@@ -526,24 +491,12 @@ async def test_submit_job_script__with_non_default_execution_directory(
         slurm_job_id = await submit_job_script(pending_job_submission, user_mapper)
 
         assert slurm_job_id == 13
-        assert submit_route.call_count == 1
         assert download_route.call_count == 1
-        last_request = submit_route.calls.last.request
-        assert last_request.method == "POST"
-        assert last_request.headers["x-slurm-user-name"] == "dummy-user"
-        assert last_request.headers["x-slurm-user-token"] == "dummy-token"
-        assert (
-            last_request.content.decode("utf-8")
-            == SlurmJobSubmission(
-                script=dummy_template_source,
-                job=job_parameters,
-            ).json()
-        )
 
 
 @pytest.mark.usefixtures("mock_access_token")
 async def test_submit_job_script__raises_exception_if_no_executable_script_was_found(
-    dummy_pending_job_submission_data, mocker
+    dummy_pending_job_submission_data, mocker, user_mapper
 ):
     """
     Test that the ``submit_job_script()`` will raise a JobSubmissionError if it cannot
@@ -555,16 +508,11 @@ async def test_submit_job_script__raises_exception_if_no_executable_script_was_f
 
     mock_mark_as_rejected = mocker.patch("jobbergate_agent.jobbergate.submit.mark_as_rejected")
 
-    async with respx.mock:
-        respx.post(f"https://{SETTINGS.OIDC_DOMAIN}/oauth/token").mock(
-            return_value=httpx.Response(
-                status_code=200,
-                json=dict(access_token="dummy-token"),
-            )
-        )
+    mocked_sbatch = mock.MagicMock()
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
 
-        with pytest.raises(JobSubmissionError, match="Could not find an executable"):
-            await submit_job_script(pending_job_submission, SingleUserMapper())
+    with pytest.raises(JobSubmissionError, match="Could not find an executable"):
+        await submit_job_script(pending_job_submission, user_mapper)
 
     mock_mark_as_rejected.assert_called_once_with(
         dummy_pending_job_submission_data["id"],
@@ -572,85 +520,69 @@ async def test_submit_job_script__raises_exception_if_no_executable_script_was_f
     )
 
 
-@pytest.mark.asyncio
 @pytest.mark.usefixtures("mock_access_token")
-async def test_submit_job_script__raises_exception_if_submit_call_response_is_not_200(
-    dummy_pending_job_submission_data, mocker, dummy_template_source
+async def test_submit_job_script__raises_exception_if_execution_dir_does_not_exist(
+    dummy_pending_job_submission_data, mocker, user_mapper
 ):
-    """
-    Test that ``submit_job_script()`` raises an exception if the response from Slurm
-    REST API is nota 200. Verifies that the error message is included in the raised
-    exception and that the ``mark_as_rejected`` method is called for the job submission.
-    """
-    user_mapper = SingleUserMapper("dummy-user")
-
     pending_job_submission = PendingJobSubmission(**dummy_pending_job_submission_data)
+    pending_job_submission.execution_directory = Path("/non/existing/path")
 
     mock_mark_as_rejected = mocker.patch("jobbergate_agent.jobbergate.submit.mark_as_rejected")
 
-    async with respx.mock:
-        respx.post(f"https://{SETTINGS.OIDC_DOMAIN}/oauth/token").mock(
-            return_value=httpx.Response(
-                status_code=200,
-                json=dict(access_token="dummy-token"),
-            )
-        )
+    mocked_sbatch = mock.MagicMock()
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
 
-        submit_route = respx.post(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/submit")
-        submit_route.mock(
-            return_value=httpx.Response(
-                status_code=400,
-                json=dict(
-                    errors=[
-                        dict(
-                            error="BOOM!",
-                            errno=13,
-                        ),
-                    ],
-                ),
-            )
-        )
+    with pytest.raises(JobSubmissionError, match="The execution directory must exist and be an absolute path"):
+        await submit_job_script(pending_job_submission, user_mapper)
 
-        download_route = respx.get(f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh")
-        download_route.mock(
-            return_value=httpx.Response(
-                status_code=200,
-                content=dummy_template_source.encode("utf-8"),
-            ),
-        )
+    mock_mark_as_rejected.assert_called_once_with(
+        dummy_pending_job_submission_data["id"],
+        RegexArgMatcher(".*The execution directory must exist and be an absolute path.*"),
+    )
 
-        with pytest.raises(
-            SlurmrestdError,
-            match="Failed to submit job to slurm",
-        ):
-            await submit_job_script(pending_job_submission, user_mapper)
 
-    assert download_route.call_count == 1
+@pytest.mark.usefixtures("mock_access_token")
+async def test_submit_job_script__raises_exception_if_execution_dir_is_relative(
+    dummy_pending_job_submission_data, mocker, user_mapper, tmp_path
+):
+    pending_job_submission = PendingJobSubmission(**dummy_pending_job_submission_data)
+    pending_job_submission.execution_directory = Path("./existing/")
+
+    submit_dir = tmp_path / pending_job_submission.execution_directory
+    submit_dir.mkdir()
+
+    mock_mark_as_rejected = mocker.patch("jobbergate_agent.jobbergate.submit.mark_as_rejected")
+
+    mocked_sbatch = mock.MagicMock()
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
+
+    with pytest.raises(JobSubmissionError, match="The execution directory must exist and be an absolute path"):
+        await submit_job_script(pending_job_submission, user_mapper)
 
     mock_mark_as_rejected.assert_called_once_with(
         dummy_pending_job_submission_data["id"],
-        RegexArgMatcher(".*Failed to submit job to slurm.*BOOM!.*"),
+        RegexArgMatcher(".*The execution directory must exist and be an absolute path.*"),
     )
 
 
 @pytest.mark.asyncio
 @pytest.mark.usefixtures("mock_access_token")
-async def test_submit_job_script__raises_exception_if_response_cannot_be_unpacked(
-    dummy_pending_job_submission_data,
-    mocker,
-    dummy_template_source,
+async def test_submit_job_script__raises_exception_if_sbatch_fails(
+    dummy_pending_job_submission_data, mocker, dummy_template_source, user_mapper
 ):
     """
-    Test that ``submit_job_script()`` raises an exception if the response from Slurm
-    REST API is nota 200. Verifies that the error message is included in the raised
+    Test that ``submit_job_script()`` raises an exception if the response from sbatch
+    is not 0. Verifies that the error message is included in the raised
     exception and that the ``mark_as_rejected`` method is called for the job submission.
     """
-    user_mapper = SingleUserMapper("dummy-user")
-
     pending_job_submission = PendingJobSubmission(**dummy_pending_job_submission_data)
 
     mock_mark_as_rejected = mocker.patch("jobbergate_agent.jobbergate.submit.mark_as_rejected")
 
+    mocked_sbatch = mock.MagicMock()
+    mocked_sbatch.submit_job.side_effect = RuntimeError("BOOM!")
+    mocker.patch("jobbergate_agent.jobbergate.submit.SubmissionHandler", return_value=mocked_sbatch)
+
     async with respx.mock:
         respx.post(f"https://{SETTINGS.OIDC_DOMAIN}/oauth/token").mock(
             return_value=httpx.Response(
@@ -659,14 +591,6 @@ async def test_submit_job_script__raises_exception_if_response_cannot_be_unpacke
             )
         )
 
-        submit_route = respx.post(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/submit")
-        submit_route.mock(
-            return_value=httpx.Response(
-                status_code=200,
-                content="BAD DATA",
-            )
-        )
-
         download_route = respx.get(f"{SETTINGS.BASE_API_URL}/jobbergate/job-scripts/1/upload/application.sh")
         download_route.mock(
             return_value=httpx.Response(
@@ -675,14 +599,17 @@ async def test_submit_job_script__raises_exception_if_response_cannot_be_unpacke
             ),
         )
 
-        with pytest.raises(SlurmrestdError, match="Failed to submit job to slurm"):
+        with pytest.raises(
+            JobSubmissionError,
+            match="Failed to submit job to slurm",
+        ):
             await submit_job_script(pending_job_submission, user_mapper)
 
     assert download_route.call_count == 1
 
     mock_mark_as_rejected.assert_called_once_with(
         dummy_pending_job_submission_data["id"],
-        RegexArgMatcher(".*Failed to submit job to slurm.*BAD DATA.*", flags=re.DOTALL),
+        RegexArgMatcher(".*Failed to submit job to slurm.*BOOM!.*"),
     )
 
 
@@ -690,7 +617,6 @@ async def test_submit_job_script__raises_exception_if_response_cannot_be_unpacke
 @pytest.mark.usefixtures("mock_access_token")
 async def test_submit_pending_jobs(
     dummy_job_script_files,
-    tweak_settings,
     dummy_template_source,
     mocker,
 ):
@@ -762,84 +688,3 @@ def _mocked_mark_as_submitted(job_submission_id: int, slurm_job_id: int):
         ]
     )
     assert mock_mark.call_count == 2
-
-
-class TestGetJobParameters:
-    """
-    Test the ``get_job_parameters()`` function.
-    """
-
-    def test_base_case__fail(self):
-        """
-        Base case should fail, since name is a required field.
-        """
-        with pytest.raises(
-            ValidationError,
-            match="1 validation error for SlurmJobParams\nname\n  field required.*",
-        ):
-            get_job_parameters(slurm_parameters={})
-
-    def test_base_case__success(self):
-        """
-        Base case should succeed with a valid name.
-        """
-        desired_value = SlurmJobParams(name="test-test")
-
-        actual_value = get_job_parameters(slurm_parameters={}, name="test-test")
-
-        assert actual_value == desired_value
-
-    def test_priority(self):
-        """
-        Test that slurm parameters have priority over extra keyword arguments.
-        """
-        desired_value = SlurmJobParams(name="high-priority")
-
-        actual_value = get_job_parameters(slurm_parameters=dict(name="high-priority"), name="test-test")
-
-        assert actual_value == desired_value
-
-    def test_extra_arguments(self):
-        """
-        Test that SlurmJobParams can be constructed with extra keyword arguments.
-        """
-        desired_value = SlurmJobParams(foo="bar", name="test-test")
-
-        actual_value = get_job_parameters(slurm_parameters=dict(foo="bar"), name="test-test")
-
-        assert actual_value == desired_value
-
-
-def test_unpack_error_from_slurm_response():
-    """
-    Test that the function ``unpack_error_from_slurm_response()`` returns the correct error.
-    """
-    response = httpx.Response(
-        status_code=400,
-        json={
-            "meta": {
-                "plugin": {"type": "openapi/v0.0.36", "name": "REST v0.0.36"},
-                "Slurm": {
-                    "version": {"major": 21, "micro": 6, "minor": 8},
-                    "release": "21.08.6",
-                },
-            },
-            "errors": [
-                {"error": "Unable to read integer value", "error_code": 9202},
-                {
-                    "error": "process failed for key wait_all_nodes with error: Unspecified error",
-                    "error_code": 1,
-                },
-                {"error_code": 9001, "error": "Failure during parsing"},
-            ],
-        },
-    )
-
-    desired_message = (
-        "Unable to read integer value;"
-        " process failed for key wait_all_nodes with error: Unspecified error;"
-        " Failure during parsing"
-    )
-    actual_error = unpack_error_from_slurm_response(SlurmSubmitResponse.parse_raw(response.content))
-
-    assert actual_error == desired_message
diff --git a/jobbergate-agent/tests/jobbergate/test_update.py b/jobbergate-agent/tests/jobbergate/test_update.py
index da7abb9a..92c23589 100644
--- a/jobbergate-agent/tests/jobbergate/test_update.py
+++ b/jobbergate-agent/tests/jobbergate/test_update.py
@@ -1,4 +1,5 @@
 import json
+from unittest import mock
 
 import httpx
 import pytest
@@ -12,7 +13,7 @@
     update_job_data,
 )
 from jobbergate_agent.settings import SETTINGS
-from jobbergate_agent.utils.exception import JobbergateApiError, SlurmrestdError
+from jobbergate_agent.utils.exception import JobbergateApiError
 
 
 @pytest.mark.asyncio
@@ -22,48 +23,26 @@ async def test_fetch_job_data__success():
     Test that the ``fetch_job_data()`` function can successfully retrieve
     job data from Slurm as a ``SlurmJobData``.
     """
-    async with respx.mock:
-        respx.get(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/123").mock(
-            return_value=httpx.Response(
-                status_code=200,
-                json=dict(
-                    jobs=[
-                        dict(
-                            job_state="FAILED",
-                            job_id=123,
-                            state_reason="NonZeroExitCode",
-                            foo="bar",
-                        ),
-                    ],
-                ),
-            )
-        )
-
-        result: SlurmJobData = await fetch_job_data(123)
-
-        assert result.job_id == 123
-        assert result.job_state == "FAILED"
-        assert result.state_reason == "NonZeroExitCode"
-        assert result.job_info is not None
-        assert json.loads(result.job_info) == dict(
-            job_state="FAILED",
-            job_id=123,
-            state_reason="NonZeroExitCode",
-            foo="bar",
-        )
-
-
-@pytest.mark.asyncio
-@pytest.mark.usefixtures("mock_access_token")
-async def test_fetch_job_data__raises_error_if_slurm_request_fails():
-    """
-    Test that the ``fetch_job_data()`` function raises an error if the slurm api request does not return 200.
-    """
-    async with respx.mock:
-        respx.get(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/123").mock(return_value=httpx.Response(status_code=400))
+    mocked_sbatch = mock.MagicMock()
+    mocked_sbatch.get_job_info.return_value = dict(
+        job_state="FAILED",
+        job_id=123,
+        state_reason="NonZeroExitCode",
+        foo="bar",
+    )
 
-        with pytest.raises(SlurmrestdError, match="Failed to fetch job state from slurm"):
-            await fetch_job_data(123)
+    result: SlurmJobData = await fetch_job_data(123, mocked_sbatch)
+
+    assert result.job_id == 123
+    assert result.job_state == "FAILED"
+    assert result.state_reason == "NonZeroExitCode"
+    assert result.job_info is not None
+    assert json.loads(result.job_info) == dict(
+        job_state="FAILED",
+        job_id=123,
+        state_reason="NonZeroExitCode",
+        foo="bar",
+    )
 
 
 @pytest.mark.asyncio
@@ -72,19 +51,15 @@ async def test_fetch_job_data__reports_status_as_UNKOWN_if_slurm_job_id_is_not_f
     """
     Test that the ``fetch_job_data()`` reports the job state as UNKNOWN if the job matching job id is not found.
     """
-    async with respx.mock:
-        respx.get(f"{SETTINGS.SLURM_RESTD_VERSIONED_URL}/job/123").mock(
-            return_value=httpx.Response(
-                status_code=404,
-            )
-        )
+    mocked_sbatch = mock.MagicMock()
+    mocked_sbatch.get_job_info.side_effect = RuntimeError("Job not found")
 
-        result: SlurmJobData = await fetch_job_data(123)
+    result: SlurmJobData = await fetch_job_data(123, mocked_sbatch)
 
-        assert result.job_id == 123
-        assert result.job_info == "{}"
-        assert result.job_state == "UNKNOWN"
-        assert result.state_reason == "Slurm did not find a job matching id 123"
+    assert result.job_id == 123
+    assert result.job_info == "{}"
+    assert result.job_state == "UNKNOWN"
+    assert result.state_reason == "Slurm did not find a job matching id 123"
 
 
 @pytest.mark.asyncio
@@ -225,6 +200,9 @@ async def test_update_active_jobs(mocker):
     retrieve the job data from slurm, and update the slurm job data on the submission via the API.
     """
 
+    mocked_sbatch = mock.MagicMock()
+    mocker.patch("jobbergate_agent.jobbergate.update.InfoHandler", return_value=mocked_sbatch)
+
     mocker.patch(
         "jobbergate_agent.jobbergate.update.fetch_active_submissions",
         return_value=[
@@ -234,7 +212,7 @@ async def test_update_active_jobs(mocker):
         ],
     )
 
-    def _mocked_fetch_job_data(slurm_job_id):
+    def _mocked_fetch_job_data(slurm_job_id, *args, **kwargs):
         if slurm_job_id == 22:
             raise Exception("BOOM!")
         return {
@@ -263,7 +241,9 @@ def _mocked_update_job_data(job_submission_id, slurm_job_data):
 
     await update_active_jobs()
 
-    mock_fetch.assert_has_calls([mocker.call(11), mocker.call(22), mocker.call(33)])
+    mock_fetch.assert_has_calls(
+        [mocker.call(11, mocked_sbatch), mocker.call(22, mocked_sbatch), mocker.call(33, mocked_sbatch)]
+    )
     assert mock_fetch.call_count == 3
 
     mock_update.assert_has_calls(
diff --git a/jobbergate-agent/tests/test_settings.py b/jobbergate-agent/tests/test_settings.py
index dffa9fd0..e69de29b 100644
--- a/jobbergate-agent/tests/test_settings.py
+++ b/jobbergate-agent/tests/test_settings.py
@@ -1,19 +0,0 @@
-from jobbergate_agent.settings import Settings
-
-
-class TestSettingsSlurmrestdUrl:
-    def test_default_version_is_added_for_backward_compatibility(self):
-        """
-        Test that the default version is added to the url if it is not present.
-        """
-        url = "http://localhost:6820"
-        settings = Settings(BASE_SLURMRESTD_URL=url)
-        assert settings.SLURM_RESTD_VERSIONED_URL == f"{url}/slurm/v0.0.36"
-
-    def test_default_version_is_not_added_if_already_present(self):
-        """
-        Test that the default version is not added to the url if it is already present.
-        """
-        url = "http://localhost:6820/slurm/v0.0.39"
-        settings = Settings(SLURM_RESTD_VERSIONED_URL=url)
-        assert settings.SLURM_RESTD_VERSIONED_URL == url
diff --git a/jobbergate-agent/tests/utils.py/test_plugin.py b/jobbergate-agent/tests/utils.py/test_plugin.py
index 86aacc5d..0895b88d 100644
--- a/jobbergate-agent/tests/utils.py/test_plugin.py
+++ b/jobbergate-agent/tests/utils.py/test_plugin.py
@@ -1,4 +1,5 @@
 """Test the plugin module."""
+
 from unittest import mock
 
 import pytest
diff --git a/jobbergate-api/tests/apps/conftest.py b/jobbergate-api/tests/apps/conftest.py
index 038bb2ed..382f03ac 100644
--- a/jobbergate-api/tests/apps/conftest.py
+++ b/jobbergate-api/tests/apps/conftest.py
@@ -1,6 +1,7 @@
 """
 Pytest helpers to use in all apps.
 """
+
 from pytest import fixture
 
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus
@@ -100,7 +101,7 @@ def job_submission_data(base_data):
         **base_data,
         "client_id": "dummy-client-id",
         "status": JobSubmissionStatus.CREATED,
-        "execution_parameters": {"name": "job-submission-name", "comment": "I am a comment"},
+        "sbatch_arguments": ["--name foo", "--comment=bar"],
     }
 
 
diff --git a/jobbergate-api/tests/apps/job_script_templates/test_routers.py b/jobbergate-api/tests/apps/job_script_templates/test_routers.py
index 237d19b5..b19a62cd 100644
--- a/jobbergate-api/tests/apps/job_script_templates/test_routers.py
+++ b/jobbergate-api/tests/apps/job_script_templates/test_routers.py
@@ -1,4 +1,5 @@
 """Test the router for the Job Script Template resource."""
+
 import json
 
 import pytest
diff --git a/jobbergate-api/tests/apps/job_script_templates/test_services.py b/jobbergate-api/tests/apps/job_script_templates/test_services.py
index 9e0394b7..71a4c956 100644
--- a/jobbergate-api/tests/apps/job_script_templates/test_services.py
+++ b/jobbergate-api/tests/apps/job_script_templates/test_services.py
@@ -1,4 +1,5 @@
 """Database models for the smart template resource."""
+
 from typing import Any
 
 import pytest
diff --git a/jobbergate-api/tests/apps/job_scripts/test_routers.py b/jobbergate-api/tests/apps/job_scripts/test_routers.py
index 510051fb..2cacb0b9 100644
--- a/jobbergate-api/tests/apps/job_scripts/test_routers.py
+++ b/jobbergate-api/tests/apps/job_scripts/test_routers.py
@@ -1,4 +1,5 @@
 """Tests for the /job-scripts/ endpoint."""
+
 import pytest
 from fastapi import HTTPException, status
 from loguru import logger
diff --git a/jobbergate-api/tests/apps/job_scripts/test_services.py b/jobbergate-api/tests/apps/job_scripts/test_services.py
index 87de0560..797a128c 100644
--- a/jobbergate-api/tests/apps/job_scripts/test_services.py
+++ b/jobbergate-api/tests/apps/job_scripts/test_services.py
@@ -1,4 +1,5 @@
 """Database models for the job scripts resource."""
+
 from itertools import product
 from typing import Any
 
diff --git a/jobbergate-api/tests/apps/job_scripts/test_tools.py b/jobbergate-api/tests/apps/job_scripts/test_tools.py
index 262016a9..39ced7c5 100644
--- a/jobbergate-api/tests/apps/job_scripts/test_tools.py
+++ b/jobbergate-api/tests/apps/job_scripts/test_tools.py
@@ -1,6 +1,7 @@
 """
 Test job-script files.
 """
+
 import snick
 
 from jobbergate_api.apps.job_scripts.tools import inject_sbatch_params
diff --git a/jobbergate-api/tests/apps/job_submissions/test_properties_parser.py b/jobbergate-api/tests/apps/job_submissions/test_properties_parser.py
deleted file mode 100644
index 5f49eb1b..00000000
--- a/jobbergate-api/tests/apps/job_submissions/test_properties_parser.py
+++ /dev/null
@@ -1,723 +0,0 @@
-"""
-Test the properties parser module.
-"""
-import inspect
-from argparse import ArgumentParser
-from typing import MutableMapping
-
-import pytest
-import snick
-from bidict import bidict
-from pydantic import ValidationError
-
-from jobbergate_api.apps.job_submissions.properties_parser import (
-    _IDENTIFICATION_FLAG,
-    _INLINE_COMMENT_MARK,
-    ArgumentParserCustomExit,
-    _clean_jobscript,
-    _clean_line,
-    _flagged_line,
-    build_mapping_sbatch_to_slurm,
-    build_parser,
-    convert_sbatch_to_slurm_api,
-    get_job_parameters,
-    get_job_properties_from_job_script,
-    jobscript_to_dict,
-    sbatch_to_slurm_mapping,
-    split,
-)
-from jobbergate_api.apps.job_submissions.schemas import JobProperties
-
-
-def test_identification_flag():
-    """
-    Check if the value of the identification flag is the same codded in the tests.
-    """
-    assert _IDENTIFICATION_FLAG == "#SBATCH"
-
-
-def test_inline_comment_mark():
-    """
-    Check if the inline comment mark is the same codded in the tests.
-    """
-    assert _INLINE_COMMENT_MARK == "#"
-
-
-@pytest.mark.parametrize(
-    "line, desired_value",
-    (
-        ("#SBATCH -abc", True),
-        ("##SBATCH -abc", False),
-        ("run python application", False),
-        ("# A comment", False),
-    ),
-)
-def test_flagged_line(line, desired_value):
-    """
-    Check if the flagged lines are identified properly.
-    """
-    actual_value = _flagged_line(line)
-    assert actual_value == desired_value
-
-
-@pytest.mark.parametrize(
-    "line, desired_value",
-    (
-        ("#SBATCH", ""),
-        ("#SBATCH#SBATCH", ""),
-        ("#SBATCH -abc # A comment", "-abc"),
-        ("#SBATCH --abc=0 # A comment", "--abc=0"),
-        ("#SBATCH --abc 0 # A comment", "--abc 0"),
-        ("#SBATCH -a 0 # A comment", "-a 0"),
-        ("#SBATCH -a=0 # A comment", "-a=0"),
-        ("#SBATCH -a = 0 # A comment", "-a = 0"),
-        ("#SBATCH -a=C&Aaccount # A comment", "-a=C&Aaccount"),
-    ),
-)
-def test_clean_line(line, desired_value):
-    """
-    Check if the provided lines are cleaned properly.
-
-    I.e., identification flag, inline comments, and comment mark are all removed.
-    """
-    actual_value = _clean_line(line)
-    assert actual_value == desired_value
-
-
-@pytest.mark.parametrize(
-    "line, desired_value",
-    (
-        ("--help", ["--help"]),
-        ("--abc=0", ["--abc=0"]),
-        ("-J job_name", ["-J", "job_name"]),
-        ("-v -J job_name", ["-v", "-J", "job_name"]),
-        ("-J job_name -v", ["-J", "job_name", "-v"]),
-        ("-a=0", ["-a=0"]),
-        ("-a = 0", ["-a", "=", "0"]),
-        ("-a 0", ["-a", "0"]),
-    ),
-)
-def test_split_line(line, desired_value):
-    """
-    Base check for shlex.split, to record the current behavior.
-    """
-    actual_value = split(line)
-    assert actual_value == desired_value
-
-
-@pytest.fixture
-def dummy_slurm_script():
-    """
-    Provide a dummy job script for testing.
-    """
-    # TODO: DRY, integrate this one with conftest/dummy_template_source
-    return inspect.cleandoc(
-        """
-        #!/bin/bash
-        #SBATCH                                 # Empty line
-        #SBATCH --no-kill --no-requeue          # Flagged params
-        #SBATCH -n 4 -A <account>               # Multiple args per line
-        #SBATCH --job-name "serial_job_test"    # Job name
-        #SBATCH --mail-type=END,FAIL            # Mail events (NONE, BEGIN, END, FAIL, ALL)
-        #SBATCH --mail-user=email@somewhere.com # Where to send mail
-        #SBATCH --mem=1gb                       # Job memory request
-        #SBATCH --time=00:05:00                 # Time limit hrs:min:sec
-        #SBATCH --output=serial_test_%j.log     # Standard output and error log
-        #SBATCH --wait-all-nodes 0              #
-        #SBATCH --acctg-freq=task=60,energy=30,network=30,filesystem=60
-        #SBATCH --comment="Hi, I am a comment"
-        pwd; hostname; date
-
-        module load python
-
-        echo "Running plot script on a single CPU core"
-
-        python /data/training/SLURM/plot_template.py
-
-        date
-        """
-    )
-
-
-def test_clean_jobscript(dummy_slurm_script):
-    """
-    Check if all sbatch parameters are correctly extracted from a job script.
-
-    This operation combines many of the functions tested above (filter,
-    clean, and slit the parameters on each line).
-    """
-    desired_list = [
-        "--no-kill",
-        "--no-requeue",
-        "-n",
-        "4",
-        "-A",
-        "<account>",
-        "--job-name",
-        "serial_job_test",
-        "--mail-type=END,FAIL",
-        "--mail-user=email@somewhere.com",
-        "--mem=1gb",
-        "--time=00:05:00",
-        "--output=serial_test_%j.log",
-        "--wait-all-nodes",
-        "0",
-        "--acctg-freq=task=60,energy=30,network=30,filesystem=60",
-        "--comment=Hi, I am a comment",
-    ]
-    actual_list = list(_clean_jobscript(dummy_slurm_script))
-    assert actual_list == desired_list
-
-
-@pytest.mark.parametrize("item", sbatch_to_slurm_mapping)
-class TestSbatchToSlurmList:
-    """
-    Test all the fields of the objects SbatchToSlurm that are stored in the list sbatch_to_slurm.
-    """
-
-    def test_slurmrestd_var_name__is_string(self, item):
-        """
-        Check if the field slurmrestd_var_name is a string.
-        """
-        assert isinstance(item.slurmrestd_var_name, str)
-
-    def test_slurmrestd_var_name__only_contains_letters(self, item):
-        """
-        Check if the field slurmrestd_var_name contains only underscores and letters.
-        """
-        if item.slurmrestd_var_name:
-            assert item.slurmrestd_var_name.replace("_", "").isalpha()
-
-    def test_sbatch__is_string(self, item):
-        """
-        Check if the field sbatch is a string.
-        """
-        assert isinstance(item.sbatch, str)
-
-    def test_sbatch__starts_with_double_hyphen(self, item):
-        """
-        Check if the field sbatch starts with a double hyphen.
-        """
-        assert item.sbatch.startswith("--")
-
-    def test_sbatch__is_not_empty(self, item):
-        """
-        Check if the field sbatch is not empty by asserting that it has more than three characters.
-
-        Notice it is supposed to start with a double hyphen.
-        """
-        assert len(item.sbatch) >= 3
-
-    def test_sbatch__only_contains_letters(self, item):
-        """
-        Check if the field sbatch contains only hyphens and letters.
-        """
-        assert item.sbatch.replace("-", "").isalpha()
-
-    def test_sbatch_short__is_string(self, item):
-        """
-        Check if the optional field sbatch_short  is a string.
-        """
-        assert isinstance(item.sbatch_short, str)
-
-    def test_sbatch_short__starts_with_hyphen(self, item):
-        """
-        Check if the optional field sbatch_short starts with a hyphen.
-        """
-        if item.sbatch_short:
-            assert item.sbatch_short.startswith("-")
-
-    def test_sbatch_short__is_not_empty(self, item):
-        """
-        Check if of the optional field sbatch_short is equal to two, since it should be a hyphen and a letter.
-        """
-        if item.sbatch_short:
-            assert len(item.sbatch_short) == 2
-
-    def test_sbatch_short__only_contains_letters(self, item):
-        """
-        Check if the optional field sbatch_short contains only hyphens and letters.
-        """
-        if item.sbatch_short:
-            assert item.sbatch_short.replace("-", "").isalpha()
-
-    def test_argparser_param__is_mutable_mapping(self, item):
-        """
-        Check if the field argparser_param is a MutableMapping.
-        """
-        assert isinstance(item.argparser_param, MutableMapping)
-
-    def test_argparser_param__is_valid_for_parser(self, item):
-        """
-        Check if the field argparser_param can be added in a parser.
-        """
-        if item.argparser_param:
-            args = (i for i in (item.sbatch_short, item.sbatch) if i)
-            parser = ArgumentParserCustomExit()
-            parser.add_argument(*args, **item.argparser_param)
-
-
-@pytest.mark.parametrize("field", ["slurmrestd_var_name", "sbatch", "sbatch_short"])
-def test_sbatch_to_slurm_list__contains_only_unique_values(field):
-    """
-    Test that any given field has no duplicated values for all parameters stored at sbatch_to_slurm.
-
-    This aims to avoid ambiguity at the SBATCH argparser and the two-way mapping between
-    SBATCH and Slurm Rest API namespaces.
-    """
-    list_of_values = [getattr(i, field) for i in sbatch_to_slurm_mapping if bool(getattr(i, field))]
-
-    assert len(list_of_values) == len(set(list_of_values))
-
-
-class TestArgumentParserCustomExit:
-    """
-    Test the custom error handling implemented over the built-in argument parser.
-    """
-
-    @pytest.fixture(scope="module")
-    def parser(self):
-        """
-        Support the tests in this class bt providing an instance of the parser.
-        """
-        parser = ArgumentParserCustomExit()
-        parser.add_argument("--foo", type=int)
-        parser.add_argument("--bar", action="store_true")
-        return parser
-
-    def test_argument_parser_success(self, parser):
-        """
-        Test the base case, where the arguments are successfully parsed and converted to the expected type.
-        """
-        args = parser.parse_args("--foo=10 --bar".split())
-        assert {"foo": 10, "bar": True} == vars(args)
-
-    def test_argument_parser_raise_value_error_when_value_is_missing(self, parser):
-        """
-        Test that ValueError is raised when the value for one parameter is missing.
-        """
-        with pytest.raises(ValueError, match="error: argument --foo: expected one argument"):
-            parser.parse_args("--foo".split())
-
-    def test_argument_parser_raise_value_error_in_case_of_wrong_type(self, parser):
-        """
-        Test that ValueError is raised when the value for some parameter is incompatible with its type.
-        """
-        with pytest.raises(ValueError, match="error: argument --foo: invalid int value:"):
-            parser.parse_args("--foo some_text".split())
-
-
-def test_build_parser():
-    """
-    Test if build_parser runs with no problem and returns the correct type.
-    """
-    parser = build_parser()
-    assert isinstance(parser, ArgumentParser)
-
-
-def test_build_mapping_sbatch_to_slurm():
-    """
-    Test if build_mapping_sbatch_to_slurm runs with no problem and returns the correct type.
-    """
-    mapping = build_mapping_sbatch_to_slurm()
-    assert isinstance(mapping, bidict)
-
-
-def test_jobscript_to_dict__success(dummy_slurm_script):
-    """
-    Test if the SBATCH parameters are properly extracted from a job script.
-
-    They are expected to be returned a dictionary mapping parameters to their value.
-    """
-    desired_dict = {
-        "no_kill": True,
-        "requeue": False,
-        "account": "<account>",
-        "job_name": "serial_job_test",
-        "mail_type": "END,FAIL",
-        "mail_user": "email@somewhere.com",
-        "mem": "1gb",
-        "ntasks": 4,
-        "output": "serial_test_%j.log",
-        "time": "00:05:00",
-        "wait_all_nodes": 0,
-        "acctg_freq": "task=60,energy=30,network=30,filesystem=60",
-        "comment": "Hi, I am a comment",
-    }
-
-    actual_dict = jobscript_to_dict(dummy_slurm_script)
-
-    assert actual_dict == desired_dict
-
-
-def test_jobscript_to_dict__raises_exception_for_unknown_parameter():
-    """
-    Test if jobscript_to_dict raises a ValueError when facing unknown parameters.
-    """
-    with pytest.raises(ValueError, match="Unrecognized SBATCH arguments:"):
-        jobscript_to_dict("#SBATCH --foo\n#SBATCH --bar=0")
-
-
-@pytest.mark.parametrize(
-    "item",
-    filter(lambda i: getattr(i, "slurmrestd_var_name", False), sbatch_to_slurm_mapping),
-)
-def test_convert_sbatch_to_slurm_api__success(item):
-    """
-    Test if the keys in a dictionary are properly renamed from SBATCH to Slurm Rest API namespace.
-
-    Notice the values should not be affected.
-    """
-    desired_dict = {item.slurmrestd_var_name: None}
-
-    sbatch_name = item.sbatch.lstrip("-").replace("-", "_")
-    actual_dict = convert_sbatch_to_slurm_api({sbatch_name: None})
-
-    assert actual_dict == desired_dict
-
-
-def test_convert_sbatch_to_slurm_api__raises_exception_for_unknown_parameter():
-    """
-    Test the conversion of dict keys from SBATCH to Slurm Rest API raises KeyError when facing unknown names.
-    """
-    with pytest.raises(KeyError, match="Impossible to convert from SBATCH to Slurm REST API:"):
-        convert_sbatch_to_slurm_api(dict(foo=0, bar=1))
-
-
-def test_get_job_parameters(dummy_slurm_script):
-    """
-    Test if all SBATCH parameters are properly extracted from a given job script.
-
-    The name of each of them is mapped to Slurm Rest API namespace and returned
-    in a dictionary. Notice get_job_parameters accepts extra keywords that can be
-    used to overwrite the values from the job script.
-    """
-    desired_dict = {
-        "no_kill": True,
-        "requeue": False,
-        "account": "<account>",
-        "name": "serial_job_test",
-        "mail_type": "END,FAIL",
-        "mail_user": "email@somewhere.com",
-        "memory_per_node": "1gb",
-        "tasks": 4,
-        "standard_output": "serial_test_%j.log",
-        "time_limit": "00:05:00",
-        "wait_all_nodes": 0,
-        "account_gather_frequency": "task=60,energy=30,network=30,filesystem=60",
-        "comment": "Hi, I am a comment",
-    }
-
-    actual_dict = get_job_parameters(dummy_slurm_script)
-
-    assert desired_dict == actual_dict
-
-
-class TestGetJobPropertiesFromJobScript:
-    """
-    Test the get_job_properties_from_job_script function.
-
-    It covers job properties obtained from the job script and from the users when creating a job submission.
-    """
-
-    def test_base_case(self):
-        """
-        Base case, not SBATCH parameters on the job script and no extra parameters.
-        """
-        script_contents = "almost-empty-file"
-        desired_job_properties = JobProperties()
-        actual_job_properties = get_job_properties_from_job_script(script_contents)
-        assert actual_job_properties == desired_job_properties
-
-    def test_properties_only_from_file(self):
-        """
-        Job properties are obtained only from the job script.
-        """
-        script_contents = snick.dedent(
-            """
-            #SBATCH --exclusive
-            #SBATCH -J test-test
-            """
-        )
-        desired_job_properties = JobProperties(exclusive="exclusive", name="test-test")
-        actual_job_properties = get_job_properties_from_job_script(script_contents)
-        assert actual_job_properties == desired_job_properties
-
-    def test_properties_only_from_arguments(self):
-        """
-        Job properties are obtained only from the extra arguments.
-        """
-        script_contents = "almost-empty-file"
-        job_properties = dict(exclusive="exclusive", name="test-test")
-        desired_job_properties = JobProperties.parse_obj(job_properties)
-        actual_job_properties = get_job_properties_from_job_script(
-            script_contents,
-            **job_properties,
-        )
-        assert actual_job_properties == desired_job_properties
-
-    def test_properties_priority_when_both_are_provided(self):
-        """
-        Job properties are obtained from the job script and from the extra arguments.
-
-        The ones from the extra arguments have priority over the ones from the job script.
-        """
-        script_contents = snick.dedent(
-            """
-            #SBATCH --exclusive
-            #SBATCH -J test-test
-            """
-        )
-        job_properties = dict(exclusive="exclusive", name="high-priority-test")
-        desired_job_properties = JobProperties.parse_obj(job_properties)
-        actual_job_properties = get_job_properties_from_job_script(
-            script_contents,
-            **job_properties,
-        )
-        assert actual_job_properties == desired_job_properties
-
-    def test_properties_fails_with_unknown_field(self):
-        """
-        The function fails when the extra arguments contain unknown fields.
-        """
-        with pytest.raises(ValidationError, match="1 validation error for JobProperties"):
-            get_job_properties_from_job_script("almost-empty-file", foo="bar")
-
-
-class TestBidictMapping:
-    """
-    Integration test with the requirement bidict (used for two-way mapping).
-    """
-
-    @pytest.fixture
-    def dummy_mapping(self):
-        """
-        Provide a dummy dictionary used for testing.
-        """
-        return {f"key_{i}": f"value_{i}" for i in range(5)}
-
-    def test_bidict__is_mutable_mapping(self):
-        """
-        Check if bidict implements all the necessary protocols to be a MutableMapping.
-        """
-        assert issubclass(bidict, MutableMapping)
-
-    def test_bidict__can_be_compared_to_a_dictionary(self, dummy_mapping):
-        """
-        Check if bidict can be really comparable to a dictionary.
-        """
-        assert dummy_mapping == bidict(dummy_mapping)
-
-    def test_bidict__can_be_compared_to_a_dictionary_inverse(self, dummy_mapping):
-        """
-        Check if bidict can be comparable to a dictionary.
-
-        This time checking its inverse capability (i.e., swapping keys and values).
-        """
-        desired_value = {value: key for key, value in dummy_mapping.items()}
-
-        assert desired_value == bidict(dummy_mapping).inverse
-
-
-class TestExclusiveParameter:
-    """
-    --exclusive is a special SBATCH parameter that can be used in some different ways.
-
-    See the details:
-    1. --exclusive as a flag, meaning the value 'exclusive' should be recovered for slurmd.
-    2. --exclusive=<value>, meaning the value <value> should be recovered for slurmd.
-    According to the Slurm documentation, the value can be 'user' or 'mcs'.
-    3. On top of that, 'exclusive' is expected to be set to 'oversubscribe' when the
-    flag --oversubscribe is used.
-
-    Note: When both are used, the last of them takes precedence.
-    """
-
-    def test_empty_jobscript(self):
-        """
-        Base case: no --exclusive parameter at all.
-        """
-        jobscript = ""
-
-        desired_dict = {}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_exclusive_as_a_flag(self):
-        """
-        Test the first scenario: --exclusive as a flag.
-        """
-        jobscript = "#SBATCH --exclusive"
-
-        desired_dict = {"exclusive": "exclusive"}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    @pytest.mark.parametrize("exclusive_value", ["user", "mcs", "exclusive", "oversubscribe"])
-    def test_exclusive_with_string_value(self, exclusive_value):
-        """
-        Test the second scenario: --exclusive=<value>.
-        """
-        jobscript = f"#SBATCH --exclusive={exclusive_value}"
-
-        desired_dict = {"exclusive": exclusive_value}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_exclusive_with_incorrect_value(self):
-        """
-        Test the second scenario with an incorrect value, ValueError should be raised.
-        """
-        jobscript = "#SBATCH --exclusive=test-test"
-
-        with pytest.raises(ValueError, match="invalid choice: 'test-test'"):
-            jobscript_to_dict(jobscript)
-
-    def test_oversubscribe(self):
-        """
-        Test the third scenario: --oversubscribe as a flag.
-        """
-        jobscript = "#SBATCH --oversubscribe"
-
-        desired_dict = {"exclusive": "oversubscribe"}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_oversubscribe_with_incorrect_value(self):
-        """
-        Test the second scenario with an incorrect value, ValueError should be raised.
-        """
-        jobscript = "#SBATCH --oversubscribe=test-test"
-
-        with pytest.raises(
-            ValueError, match="argument -s/--oversubscribe: ignored explicit argument 'test-test'"
-        ):
-            jobscript_to_dict(jobscript)
-
-    def test_both_exclusive_and_oversubscribe_1(self):
-        """
-        Test that when both are used, the last of them takes precedence.
-
-        In this case, --exclusive is the last one.
-        """
-        jobscript = "#SBATCH --oversubscribe\n#SBATCH --exclusive"
-
-        desired_dict = {"exclusive": "exclusive"}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_both_exclusive_and_oversubscribe_2(self):
-        """
-        Test that when both are used, the last of them takes precedence.
-
-        In this case, --oversubscribe is the last one.
-        """
-        jobscript = "#SBATCH --exclusive\n#SBATCH --oversubscribe"
-
-        desired_dict = {"exclusive": "oversubscribe"}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-
-class TestRequeueParameter:
-    """
-    Requeue is a slurm parameter whose value comes from two sbatch flags: --requeue and --no-requeue.
-    """
-
-    def test_empty_jobscript(self):
-        """
-        Base case: no parameters at all.
-        """
-        jobscript = ""
-
-        desired_dict = {}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_requeue_flag(self):
-        """
-        Test the first scenario: --requeue.
-        """
-        jobscript = "#SBATCH --requeue"
-
-        desired_dict = {"requeue": True}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_no_requeue_flag(self):
-        """
-        Test the first scenario: --no-requeue.
-        """
-        jobscript = "#SBATCH --no-requeue"
-
-        desired_dict = {"requeue": False}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_both_exclusive_and_oversubscribe_1(self):
-        """
-        Test that when both are used, the last of them takes precedence.
-
-        In this case, --no-requeue is the last one.
-        """
-        jobscript = "#SBATCH --requeue\n#SBATCH --no-requeue"
-
-        desired_dict = {"requeue": False}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-    def test_both_exclusive_and_oversubscribe_2(self):
-        """
-        Test that when both are used, the last of them takes precedence.
-
-        In this case, --requeue is the last one.
-        """
-        jobscript = "#SBATCH --no-requeue\n#SBATCH --requeue"
-
-        desired_dict = {"requeue": True}
-
-        actual_dict = jobscript_to_dict(jobscript)
-
-        assert actual_dict == desired_dict
-
-
-@pytest.mark.parametrize(
-    "input, expected_result",
-    (
-        (None, None),
-        ("USR1@7200", "USR1@7200"),
-        ("B:USR1@7200", "USR1@7200"),
-        ("R:USR1@7200", "R:USR1@7200"),
-        ("RB:USR1@7200", "R:USR1@7200"),
-        ("BR:USR1@7200", "R:USR1@7200"),
-        ("B:BBB", "BBB"),  # a silly case, but let's assert that just the B on the left side is removed
-    ),
-)
-def test_backward_compatibility_on_signal_parameter(input, expected_result):
-    """
-    Test backward compatibility on signal parameter.
-
-    It is expected to remove the 'B' from the prefix while preserving any other, like the 'R'.
-    """
-    actual_result = JobProperties(signal=input).signal
-    assert actual_result == expected_result
diff --git a/jobbergate-api/tests/apps/job_submissions/test_routers.py b/jobbergate-api/tests/apps/job_submissions/test_routers.py
index 820235a3..9cf50373 100644
--- a/jobbergate-api/tests/apps/job_submissions/test_routers.py
+++ b/jobbergate-api/tests/apps/job_submissions/test_routers.py
@@ -1,14 +1,13 @@
 """
 Tests for the /job-submissions/ endpoint.
 """
+
 import json
-from unittest import mock
 
 import pytest
 from fastapi import status
 
 from jobbergate_api.apps.job_submissions.constants import JobSubmissionStatus, SlurmJobState
-from jobbergate_api.apps.job_submissions.schemas import JobProperties
 from jobbergate_api.apps.permissions import Permissions
 from jobbergate_api.rabbitmq_notification import rabbitmq_connect
 
@@ -33,7 +32,7 @@ async def test_create_job_submission__on_site_submission(
     This test proves that a job_submission is successfully created via a POST request to the /job-submissions/
     endpoint. We show this by asserting that the job_submission is created in the database after the post
     request is made, the correct status code (201) is returned. We also show that the ``status``
-    is ``SUBMITTED`` and the ``execution_parameters`` were not processed.
+    is ``SUBMITTED``.
     """
     base_job_script = await synth_services.crud.job_script.create(**fill_job_script_data())
 
@@ -55,13 +54,8 @@ async def test_create_job_submission__on_site_submission(
     # Removed defaults to make sure these are correctly set by other mechanisms
     create_data.pop("status", None)
     create_data.pop("client_id", None)
-    create_data.pop("execution_parameters", None)
 
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.get_job_properties_from_job_script"
-    ) as mocked:
-        response = await client.post("/jobbergate/job-submissions", json=create_data)
-    mocked.assert_not_called()
+    response = await client.post("/jobbergate/job-submissions", json=create_data)
 
     assert response.status_code == status.HTTP_201_CREATED, f"Create failed: {response.text}"
 
@@ -78,52 +72,7 @@ async def test_create_job_submission__on_site_submission(
     assert response_data["execution_directory"] is None
     assert response_data["client_id"] == "dummy-cluster-client"
     assert response_data["status"] == JobSubmissionStatus.SUBMITTED
-
-    assert isinstance(response_data["execution_parameters"], dict)
-    assert {k: v for k, v in response_data["execution_parameters"].items() if v is not None} == {}
-
-
-async def test_create_job_submission__on_site_submission_with_execution_parameters(
-    fill_job_script_data,
-    fill_job_submission_data,
-    client,
-    inject_security_header,
-    tester_email,
-    job_script_data_as_string,
-    synth_session,
-    synth_bucket,
-    synth_services,
-):
-    """
-    Test POST /job-submissions/ correctly creates a job_submission.
-    """
-    base_job_script = await synth_services.crud.job_script.create(**fill_job_script_data())
-
-    job_script_file_name = "entrypoint.sh"
-
-    await synth_services.file.job_script.upsert(
-        parent_id=base_job_script.id,
-        filename=job_script_file_name,
-        upload_content=job_script_data_as_string,
-        file_type="ENTRYPOINT",
-    )
-
-    inserted_job_script_id = base_job_script.id
-    slurm_job_id = 1234
-
-    inject_security_header(tester_email, Permissions.JOB_SUBMISSIONS_EDIT, client_id="dummy-cluster-client")
-    create_data = fill_job_submission_data(
-        job_script_id=inserted_job_script_id, slurm_job_id=slurm_job_id, execution_parameters={"name": "foo"}
-    )
-
-    # Removed defaults to make sure these are correctly set by other mechanisms
-    create_data.pop("status", None)
-    create_data.pop("client_id", None)
-
-    response = await client.post("/jobbergate/job-submissions", json=create_data)
-
-    assert response.status_code == status.HTTP_400_BAD_REQUEST
-    assert "Execution parameters are not allowed for on-site job submissions" in response.text
+    assert response_data["sbatch_arguments"] == ["--name foo", "--comment=bar"]
 
 
 async def test_create_job_submission__with_client_id_in_token(
@@ -165,15 +114,7 @@ async def test_create_job_submission__with_client_id_in_token(
     create_data.pop("status", None)
     create_data.pop("client_id", None)
 
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.get_job_properties_from_job_script"
-    ) as mocked:
-        mocked.return_value = JobProperties.parse_obj(create_data["execution_parameters"])
-        response = await client.post("/jobbergate/job-submissions", json=create_data)
-    mocked.assert_called_once_with(
-        job_script_data_as_string,
-        **create_data["execution_parameters"],
-    )
+    response = await client.post("/jobbergate/job-submissions", json=create_data)
 
     assert response.status_code == status.HTTP_201_CREATED, f"Create failed: {response.text}"
 
@@ -190,10 +131,7 @@ async def test_create_job_submission__with_client_id_in_token(
     assert response_data["execution_directory"] is None
     assert response_data["client_id"] == "dummy-cluster-client"
     assert response_data["status"] == JobSubmissionStatus.CREATED
-
-    assert response_data["execution_parameters"] is not None
-    assert response_data["execution_parameters"]["name"] == "job-submission-name"
-    assert response_data["execution_parameters"]["comment"] == "I am a comment"
+    assert response_data["sbatch_arguments"] == ["--name foo", "--comment=bar"]
 
     created_id = response_data["id"]
 
@@ -209,74 +147,7 @@ async def test_create_job_submission__with_client_id_in_token(
     assert response_data["execution_directory"] is None
     assert response_data["client_id"] == "dummy-cluster-client"
     assert response_data["status"] == JobSubmissionStatus.CREATED
-
-
-async def test_create_job_submission__without_execution_parameters(
-    fill_job_script_data,
-    fill_job_submission_data,
-    client,
-    inject_security_header,
-    tester_email,
-    job_script_data_as_string,
-    synth_services,
-):
-    """
-    Test POST /job-submissions/ correctly creates a job_submission.
-
-    This test proves that a job_submission is successfully created via a POST request to the /job-submissions/
-    endpoint. We show this by asserting that the job_submission is created in the database after the post
-    request is made, the correct status code (201) is returned. We also show that the ``client_id``
-    is pulled from the token and the created job_submission is connected to that client id.
-
-    This test is the same as the previous one, but it does not include the ``execution_parameters``
-    in the payload, since they are optional.
-    """
-    base_job_script = await synth_services.crud.job_script.create(**fill_job_script_data())
-
-    job_script_file_name = "entrypoint.sh"
-
-    await synth_services.file.job_script.upsert(
-        parent_id=base_job_script.id,
-        filename=job_script_file_name,
-        upload_content=job_script_data_as_string,
-        file_type="ENTRYPOINT",
-    )
-
-    inserted_job_script_id = base_job_script.id
-
-    inject_security_header(tester_email, Permissions.JOB_SUBMISSIONS_EDIT, client_id="dummy-cluster-client")
-    create_data = fill_job_submission_data(job_script_id=inserted_job_script_id)
-
-    # This test aims to prove that the execution_parameters are optional
-    create_data.pop("execution_parameters", None)
-
-    # Removed defaults to make sure these are correctly set by other mechanisms
-    create_data.pop("status", None)
-    create_data.pop("client_id", None)
-
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.get_job_properties_from_job_script"
-    ) as mocked:
-        mocked.return_value = JobProperties()
-        response = await client.post("/jobbergate/job-submissions", json=create_data)
-        mocked.assert_called_once_with(job_script_data_as_string)
-
-    assert response.status_code == status.HTTP_201_CREATED, f"Create failed: {response.text}"
-
-    assert (await synth_services.crud.job_submission.count()) == 1
-
-    response_data = response.json()
-
-    # Check that each field is correctly set
-    assert response_data["name"] == create_data["name"]
-    assert response_data["owner_email"] == tester_email
-    assert response_data["description"] == create_data["description"]
-    assert response_data["job_script_id"] == inserted_job_script_id
-    assert response_data["execution_directory"] is None
-    assert response_data["client_id"] == "dummy-cluster-client"
-    assert response_data["status"] == JobSubmissionStatus.CREATED
-
-    assert response_data["execution_parameters"] is not None
+    assert response_data["sbatch_arguments"] == ["--name foo", "--comment=bar"]
 
 
 async def test_create_job_submission__with_client_id_in_request_body(
@@ -318,15 +189,7 @@ async def test_create_job_submission__with_client_id_in_request_body(
     # Change the client_id
     create_data["client_id"] = "silly-cluster-client"
 
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.get_job_properties_from_job_script"
-    ) as mocked:
-        mocked.return_value = JobProperties.parse_obj(create_data["execution_parameters"])
-        response = await client.post("/jobbergate/job-submissions", json=create_data)
-    mocked.assert_called_once_with(
-        job_script_data_as_string,
-        **create_data["execution_parameters"],
-    )
+    response = await client.post("/jobbergate/job-submissions", json=create_data)
 
     assert response.status_code == status.HTTP_201_CREATED, f"Create failed: {response.text}"
 
@@ -342,10 +205,7 @@ async def test_create_job_submission__with_client_id_in_request_body(
     assert response_data["execution_directory"] is None
     assert response_data["client_id"] == "silly-cluster-client"
     assert response_data["status"] == JobSubmissionStatus.CREATED
-
-    assert response_data["execution_parameters"] is not None
-    assert response_data["execution_parameters"]["name"] == "job-submission-name"
-    assert response_data["execution_parameters"]["comment"] == "I am a comment"
+    assert response_data["sbatch_arguments"] == ["--name foo", "--comment=bar"]
 
 
 async def test_create_job_submission__with_execution_directory(
@@ -388,15 +248,7 @@ async def test_create_job_submission__with_execution_directory(
     create_data.pop("status", None)
     create_data.pop("client_id", None)
 
-    with mock.patch(
-        "jobbergate_api.apps.job_submissions.routers.get_job_properties_from_job_script"
-    ) as mocked:
-        mocked.return_value = JobProperties.parse_obj(create_data["execution_parameters"])
-        response = await client.post("/jobbergate/job-submissions", json=create_data)
-        mocked.assert_called_once_with(
-            job_script_data_as_string,
-            **create_data["execution_parameters"],
-        )
+    response = await client.post("/jobbergate/job-submissions", json=create_data)
 
     assert response.status_code == status.HTTP_201_CREATED, f"Create failed: {response.text}"
 
@@ -412,10 +264,7 @@ async def test_create_job_submission__with_execution_directory(
     assert response_data["execution_directory"] == "/some/fake/path"
     assert response_data["client_id"] == "dummy-cluster-client"
     assert response_data["status"] == JobSubmissionStatus.CREATED
-
-    assert response_data["execution_parameters"] is not None
-    assert response_data["execution_parameters"]["name"] == "job-submission-name"
-    assert response_data["execution_parameters"]["comment"] == "I am a comment"
+    assert response_data["sbatch_arguments"] == ["--name foo", "--comment=bar"]
 
 
 async def test_create_job_submission_without_job_script(
@@ -525,9 +374,6 @@ async def test_get_job_submission_by_id(
     assert response_data["owner_email"] == tester_email
     assert response_data["job_script_id"] is None
 
-    assert response_data["execution_parameters"]["name"] == "job-submission-name"
-    assert response_data["execution_parameters"]["comment"] == "I am a comment"
-
 
 async def test_get_job_submission_by_id_bad_permission(
     client,
@@ -588,28 +434,16 @@ async def test_get_job_submissions__no_param(
             job_script_id=inserted_job_script_id,
             name="sub1",
             owner_email="owner1@org.com",
-            execution_parameters={
-                "name": "job-submission-name-1",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
             name="sub2",
             owner_email="owner999@org.com",
-            execution_parameters={
-                "name": "job-submission-name-2",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
             name="sub3",
             owner_email="owner1@org.com",
-            execution_parameters={
-                "name": "job-submission-name-3",
-                "comment": "I am a comment",
-            },
         ),
     )
 
@@ -686,28 +520,16 @@ async def test_get_job_submissions__user_only(
             job_script_id=inserted_job_script_id,
             name="sub1",
             owner_email="owner1@org.com",
-            execution_parameters={
-                "name": "job-submission-name-1",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
             name="sub2",
             owner_email="owner999@org.com",
-            execution_parameters={
-                "name": "job-submission-name-2",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
             name="sub3",
             owner_email="owner1@org.com",
-            execution_parameters={
-                "name": "job-submission-name-3",
-                "comment": "I am a comment",
-            },
         ),
     )
 
@@ -1372,10 +1194,6 @@ async def test_job_submissions_agent_pending__success(
             owner_email="email1@dummy.com",
             status=JobSubmissionStatus.CREATED,
             client_id="dummy-client",
-            execution_parameters={
-                "name": "job-submission-name-1",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
@@ -1383,10 +1201,6 @@ async def test_job_submissions_agent_pending__success(
             owner_email="email2@dummy.com",
             status=JobSubmissionStatus.DONE,
             client_id="dummy-client",
-            execution_parameters={
-                "name": "job-submission-name-2",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
@@ -1394,10 +1208,6 @@ async def test_job_submissions_agent_pending__success(
             owner_email="email3@dummy.com",
             status=JobSubmissionStatus.CREATED,
             client_id="silly-client",
-            execution_parameters={
-                "name": "job-submission-name-3",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             job_script_id=inserted_job_script_id,
@@ -1405,14 +1215,11 @@ async def test_job_submissions_agent_pending__success(
             owner_email="email4@dummy.com",
             status=JobSubmissionStatus.CREATED,
             client_id="dummy-client",
-            execution_parameters={
-                "name": "job-submission-name-4",
-                "comment": "I am a comment",
-            },
         ),
     )
 
     for item in submission_list:
+        item["sbatch_arguments"] = f"--comment={item['name']}"
         await synth_services.crud.job_submission.create(**item)
 
     inject_security_header(
@@ -1431,6 +1238,7 @@ async def test_job_submissions_agent_pending__success(
         "email4@dummy.com",
     ]
     assert [i["job_script"]["id"] for i in data["items"]] == [inserted_job_script_id] * 2
+    assert [i["sbatch_arguments"] for i in data["items"]] == [i["sbatch_arguments"] for i in data["items"]]
 
     assert all(len(i["job_script"]["files"]) >= 1 for i in data["items"])
 
diff --git a/jobbergate-api/tests/apps/test_main.py b/jobbergate-api/tests/apps/test_main.py
index 4002dc89..810a3668 100644
--- a/jobbergate-api/tests/apps/test_main.py
+++ b/jobbergate-api/tests/apps/test_main.py
@@ -1,6 +1,7 @@
 """
 Provide unit tests for the main app.
 """
+
 import pytest
 from fastapi import status
 from httpx import AsyncClient
diff --git a/jobbergate-api/tests/apps/test_models.py b/jobbergate-api/tests/apps/test_models.py
index 46247758..da87e266 100644
--- a/jobbergate-api/tests/apps/test_models.py
+++ b/jobbergate-api/tests/apps/test_models.py
@@ -1,4 +1,5 @@
 """Test the the models."""
+
 import pytest
 from sqlalchemy import String
 
diff --git a/jobbergate-api/tests/integration/draft_test_jobbergate_slurm_job_properties.py b/jobbergate-api/tests/integration/draft_test_jobbergate_slurm_job_properties.py
deleted file mode 100644
index 52cb0528..00000000
--- a/jobbergate-api/tests/integration/draft_test_jobbergate_slurm_job_properties.py
+++ /dev/null
@@ -1,151 +0,0 @@
-# type: ignore
-"""
-Integration tests for the job properties.
-
-This is a draft of the tests that may be implemented in the future,
-it was added to the version control as a reference for some of the
-job properties that were already tested here.
-
-In order to run these tests, you need to:
-* Spin up jobbergate-composed (and have cloned of cluster-agent for that)
-* Add hypothesis as a dependency for Jobbergate API
-* Remove the prefix `draft` from the filename, so pytest can find it
-
-Test based on cluster-agent >= 2.1.0.
-"""
-
-import json
-from datetime import datetime, timedelta
-
-import pydantic
-import pytest
-import requests
-from hypothesis import example, given, settings
-from hypothesis import strategies as st
-from jose import jwt
-from loguru import logger
-from starlette import status
-
-from jobbergate_api.apps.job_submissions.schemas import JobProperties
-
-
-@pytest.fixture(scope="module")
-def token():
-    """
-    Create a JWT token for the local-user.
-    """
-    now = datetime.now()
-    payload = {
-        "exp": int(datetime.timestamp(now + timedelta(seconds=900))),
-        "iat": int(datetime.timestamp(now)),
-        "sun": "local-user",
-    }
-    return jwt.encode(payload, "supersecret", algorithm="HS256")
-
-
-class SlurmJobSubmission(pydantic.BaseModel):
-    """
-    Specialized model for describing a request to submit a job to Slurm REST API.
-    """
-
-    script: str
-    job: JobProperties
-
-
-def test_integration_ping(token):
-    """
-    Test that slurmrestd is up and running.
-    """
-    response = requests.get(
-        url="http://localhost:6820/slurm/v0.0.36/ping",
-        headers={
-            "x-slurm-user-name": "local-user",
-            "x-slurm-user-token": token,
-        },
-    )
-    response.raise_for_status()
-
-
-def submit_job_to_slurm(payload_json, token):
-    """
-    Test helper, used to submit a job to slurmrestd.
-    """
-    logger.debug(f"Payload: {payload_json}")
-
-    response = requests.post(
-        url="http://localhost:6820/slurm/v0.0.36/job/submit",
-        headers={
-            "x-slurm-user-name": "local-user",
-            "x-slurm-user-token": token,
-        },
-        json=payload_json,
-    )
-    logger.debug(f"Slurmrestd response: {response.json()}")
-
-    return response
-
-
-@settings(deadline=None, report_multiple_bugs=True)
-@given(
-    instance=st.builds(
-        JobProperties,
-        get_user_environment=st.integers(min_value=0, max_value=1),
-        hold=st.booleans(),
-        kill_on_invalid_dependency=st.booleans(),
-        memory_per_node=st.integers(min_value=1, max_value=100),
-        requeue=st.booleans(),
-        spread_job=st.booleans(),
-        wait_all_nodes=st.integers(min_value=0, max_value=1),  # not bool as slurm's documentation suggests
-    )
-)
-@example(instance=JobProperties(nodes="1-2"))
-@example(instance=JobProperties(nodes="2"))
-@example(instance=JobProperties(memory_per_node="1MB"))
-@example(instance=JobProperties(time_limit="1:00:00"))
-@example(instance=JobProperties(standard_output="/tmp"))
-@example(instance=JobProperties(open_mode="append"))
-@example(instance=JobProperties(open_mode="truncate"))
-def test_integration_job_submit__success(instance, token):
-    """
-    Test that we can submit a job to slurmrestd.
-    """
-    dummy_job_script = "#!/bin/bash"
-
-    payload = SlurmJobSubmission(script=dummy_job_script, job=instance)
-    payload_json = json.loads(payload.json(exclude_none=True))
-
-    response = submit_job_to_slurm(payload_json, token)
-    response.raise_for_status()
-
-
-@pytest.mark.parametrize(
-    "job_properties",
-    [
-        {"nodes": [1, 2, 3]},
-        {"nodes": [1]},
-        # {"nodes": "1-2"}, this one works
-        # {"nodes": 1}, this one works
-        # {"nodes": "1"}, this one works
-        {"no_kill": True},
-        {"no_kill": False},
-        {"no_kill": 1},
-        {"no_kill": 0},
-        # {"no_kill": None}, this one works
-        {"time_minimum": 10},
-        {"time_minimum": "1:00:00"},
-        {"wait_all_nodes": True},
-        {"wait_all_nodes": False},
-    ],
-)
-def test_integration_job_submit__known_errors(job_properties, token):
-    """
-    Test that we get a 400 error when submitting a job with invalid properties.
-    """
-    job_properties["get_user_environment"] = 0
-
-    payload_json = dict(script="#!/bin/bash", job=job_properties)
-
-    response = submit_job_to_slurm(payload_json, token)
-    with pytest.raises(requests.exceptions.HTTPError):
-        response.raise_for_status()
-        assert response.status_code == status.HTTP_400_BAD_REQUEST
diff --git a/jobbergate-api/tests/test_email_notification.py b/jobbergate-api/tests/test_email_notification.py
index 7b65d6f0..f21df325 100644
--- a/jobbergate-api/tests/test_email_notification.py
+++ b/jobbergate-api/tests/test_email_notification.py
@@ -1,6 +1,7 @@
 """
 Test the email notification system at Jobbergate.
 """
+
 from unittest import mock
 
 import pytest
diff --git a/jobbergate-api/tests/test_security.py b/jobbergate-api/tests/test_security.py
index 4e67510b..6dac9e98 100644
--- a/jobbergate-api/tests/test_security.py
+++ b/jobbergate-api/tests/test_security.py
@@ -1,6 +1,7 @@
 """
 Test the security module.
 """
+
 from unittest.mock import patch
 
 import pytest
diff --git a/jobbergate-cli/tests/subapps/conftest.py b/jobbergate-cli/tests/subapps/conftest.py
index 57e30045..241ef369 100644
--- a/jobbergate-cli/tests/subapps/conftest.py
+++ b/jobbergate-cli/tests/subapps/conftest.py
@@ -181,10 +181,6 @@ def dummy_job_submission_data(dummy_job_script_data):
             job_script_id=dummy_job_script_data[0]["id"],
             slurm_job_id=13,
             status="CREATED",
-            execution_parameters={
-                "name": "job-submission-name-1",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             id=1,
@@ -196,10 +192,6 @@ def dummy_job_submission_data(dummy_job_script_data):
             job_script_id=88,
             slurm_job_id=8888,
             status="CREATED",
-            execution_parameters={
-                "name": "job-submission-name-2",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             id=3,
@@ -211,10 +203,6 @@ def dummy_job_submission_data(dummy_job_script_data):
             job_script_id=99,
             slurm_job_id=9999,
             status="CREATED",
-            execution_parameters={
-                "name": "job-submission-name-3",
-                "comment": "I am a comment",
-            },
         ),
         dict(
             id=4,
@@ -227,10 +215,6 @@ def dummy_job_submission_data(dummy_job_script_data):
             slurm_job_id=9999,
             status="REJECTED",
             report_message="Failed to submit job to slurm",
-            execution_parameters={
-                "name": "job-submission-name-4",
-                "comment": "I am a comment",
-            },
         ),
     ]
 
diff --git a/jobbergate-cli/tests/subapps/job_scripts/test_app.py b/jobbergate-cli/tests/subapps/job_scripts/test_app.py
index 284c07f0..7cb594e1 100644
--- a/jobbergate-cli/tests/subapps/job_scripts/test_app.py
+++ b/jobbergate-cli/tests/subapps/job_scripts/test_app.py
@@ -248,10 +248,13 @@ def test_render__non_fast_mode_and_job_submission(
             content=dummy_module_source.encode(),
         ),
     )
-    mocked_create_job_submission = mocker.patch(
-        "jobbergate_cli.subapps.job_scripts.app.create_job_submission",
-        return_value=JobSubmissionResponse.parse_obj(job_submission_data),
+
+    submissions_handler = mock.MagicMock()
+    submissions_handler.run.return_value = JobSubmissionResponse.parse_obj(job_submission_data)
+    mocked_factory = mocker.patch(
+        "jobbergate_cli.subapps.job_scripts.app.job_submissions_factory", return_value=submissions_handler
     )
+
     mocker.patch.object(
         importlib.import_module("inquirer.prompt"),
         "ConsoleRender",
@@ -278,15 +281,7 @@ def test_render__non_fast_mode_and_job_submission(
         id=application_response.application_id,
         identifier=None,
     )
-    mocked_create_job_submission.assert_called_once_with(
-        jg_ctx=dummy_context,
-        job_script_id=job_script_data["id"],
-        name=job_script_data["name"],
-        description=job_script_data["description"],
-        cluster_name=None,
-        execution_directory=None,
-        execution_parameters_file=None,
-    )
+
     assert render_route.call_count == 1
     content = json.loads(render_route.calls.last.request.content)
     assert content == {
@@ -308,6 +303,16 @@ def test_render__non_fast_mode_and_job_submission(
         },
     }
 
+    mocked_factory.assert_called_once_with(
+        jg_ctx=dummy_context,
+        job_script_id=job_script_data["id"],
+        name=job_script_data["name"],
+        description=job_script_data["description"],
+        cluster_name=None,
+        execution_directory=None,
+        sbatch_arguments=None,
+    )
+
     mocked_render.assert_has_calls(
         [
             mocker.call(
diff --git a/jobbergate-cli/tests/subapps/job_submissions/test_app.py b/jobbergate-cli/tests/subapps/job_submissions/test_app.py
index 5732979c..e3c4331c 100644
--- a/jobbergate-cli/tests/subapps/job_submissions/test_app.py
+++ b/jobbergate-cli/tests/subapps/job_submissions/test_app.py
@@ -1,5 +1,5 @@
-import json
 import shlex
+from unittest import mock
 
 import httpx
 import pytest
@@ -22,7 +22,6 @@ def test_create(
     dummy_job_submission_data,
     cli_runner,
     mocker,
-    tmp_path,
     flag_name,
     flag_job_script_id,
     separator,
@@ -32,14 +31,13 @@ def test_create(
     job_submission_description = job_submission_data.description
     job_script_id = job_submission_data.job_script_id
 
-    param_file_path = tmp_path / "param_file.json"
-    param_file_path.write_text(json.dumps(job_submission_data.execution_parameters))
-
     mocked_render = mocker.patch("jobbergate_cli.subapps.job_submissions.app.render_single_result")
-    patched_create_job_submission = mocker.patch(
-        "jobbergate_cli.subapps.job_submissions.app.create_job_submission",
+
+    submissions_handler = mock.MagicMock()
+    submissions_handler.run.return_value = job_submission_data
+    mocked_factory = mocker.patch(
+        "jobbergate_cli.subapps.job_submissions.app.job_submissions_factory", return_value=submissions_handler
     )
-    patched_create_job_submission.return_value = job_submission_data
 
     test_app = make_test_app("create", create)
     result = cli_runner.invoke(
@@ -50,7 +48,6 @@ def test_create(
                 create {flag_name}{separator}{job_submission_name}
                        --description='{job_submission_description}'
                        {flag_job_script_id}{separator}{job_script_id}
-                       --execution-parameters={param_file_path}
                        --download
                 """
             )
@@ -58,6 +55,17 @@ def test_create(
     )
     assert result.exit_code == 0, f"create failed: {result.stdout}"
 
+    mocked_factory.assert_called_once_with(
+        dummy_context,
+        job_script_id,
+        job_submission_name,
+        description=job_submission_description,
+        execution_directory=None,
+        cluster_name=None,
+        sbatch_arguments=[],
+        download=True,
+    )
+
     mocked_render.assert_called_once_with(
         dummy_context,
         job_submission_data,
diff --git a/jobbergate-cli/tests/subapps/job_submissions/test_tools.py b/jobbergate-cli/tests/subapps/job_submissions/test_tools.py
index daa8d4d0..9f5f3fc1 100644
--- a/jobbergate-cli/tests/subapps/job_submissions/test_tools.py
+++ b/jobbergate-cli/tests/subapps/job_submissions/test_tools.py
@@ -1,322 +1,389 @@
-import json
 import pathlib
+from unittest import mock
 
 import httpx
 import pytest
 
 from jobbergate_cli.exceptions import Abort
-from jobbergate_cli.schemas import JobSubmissionResponse
-from jobbergate_cli.subapps.job_submissions.tools import create_job_submission, fetch_job_submission_data, sbatch_run
+from jobbergate_cli.schemas import JobScriptResponse, JobSubmissionCreateRequestData, JobSubmissionResponse
+from jobbergate_cli.subapps.job_submissions.tools import (
+    OnsiteJobSubmission,
+    RemoteJobSubmission,
+    fetch_job_submission_data,
+    job_submissions_factory,
+)
+
+
+@pytest.mark.parametrize("submission_cls", [OnsiteJobSubmission, RemoteJobSubmission])
+@pytest.mark.parametrize("organization_id", [None, "", "some-organization"])
+class TestJobSubmissionsClusterName:
+    def test_with_explicit_cluster_name(
+        self, dummy_context, attach_persona, submission_cls, organization_id, tweak_settings
+    ):
+        cluster_name = "test-cluster"
+        attach_persona("dummy@dummy.com", organization_id=organization_id)
+        with tweak_settings(DEFAULT_CLUSTER_NAME="default-cluster"):
+            submission_handler = submission_cls(
+                jg_ctx=dummy_context,
+                job_script_id=1,
+                name="test",
+                cluster_name=cluster_name,
+            )
+        expected_cluster_name = cluster_name
+        if organization_id:
+            expected_cluster_name += f"-{organization_id}"
+        assert submission_handler.cluster_name == expected_cluster_name
+
+    def test_default_cluster_name(self, dummy_context, attach_persona, submission_cls, organization_id, tweak_settings):
+        cluster_name = "default-cluster"
+        attach_persona("dummy@dummy.com", organization_id=organization_id)
+        with tweak_settings(DEFAULT_CLUSTER_NAME=cluster_name):
+            submission_handler = submission_cls(
+                jg_ctx=dummy_context,
+                job_script_id=1,
+                name="test",
+            )
+        expected_cluster_name = cluster_name
+        if organization_id:
+            expected_cluster_name += f"-{organization_id}"
+        assert submission_handler.cluster_name == expected_cluster_name
+
+    def test_throws_exception_with_no_explicit_or_default_cluster_name(
+        self, dummy_context, attach_persona, submission_cls, organization_id, tweak_settings
+    ):
+        attach_persona("dummy@dummy.com", organization_id=organization_id)
+        with (
+            tweak_settings(DEFAULT_CLUSTER_NAME=None),
+            pytest.raises(ValueError, match="No cluster name supplied and no default exists"),
+        ):
+            submission_cls(
+                jg_ctx=dummy_context,
+                job_script_id=1,
+                name="test",
+            )
+
+    def test_with_organization_id_on_cluster_name(
+        self, dummy_context, attach_persona, submission_cls, organization_id, tweak_settings
+    ):
+        cluster_name = "test-cluster"
+        if organization_id:
+            cluster_name += f"-{organization_id}"
+        attach_persona("dummy@dummy.com", organization_id=organization_id)
+        with tweak_settings(DEFAULT_CLUSTER_NAME="default-cluster"):
+            submission_handler = submission_cls(
+                jg_ctx=dummy_context,
+                job_script_id=1,
+                name="test",
+                cluster_name=cluster_name,
+            )
+        assert submission_handler.cluster_name == cluster_name
+
+
+@pytest.mark.parametrize("submission_cls", [OnsiteJobSubmission, RemoteJobSubmission])
+@pytest.mark.parametrize(
+    "execution_directory", [pathlib.Path("./some/relative/path"), pathlib.Path("/some/absolute/path"), None]
+)
+class TestJobSubmissionsExecutionDirectory:
+    def test_execution_directory_is_absolute(self, dummy_context, attach_persona, submission_cls, execution_directory):
+        attach_persona("dummy@dummy.com")
+
+        submission_handler = submission_cls(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            execution_directory=execution_directory,
+            cluster_name="test-cluster",
+        )
 
+        assert submission_handler.execution_directory.is_absolute()
 
-def test_create_job_submission__success(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        if execution_directory is None:
+            # Default value
+            execution_directory = pathlib.Path.cwd()
 
-    job_script_id = job_submission_data["job_script_id"]
+        expected_execution_directory = execution_directory.resolve()
+        assert submission_handler.execution_directory == expected_execution_directory
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
 
-    attach_persona("dummy@dummy.com")
-    seed_clusters("dummy-cluster")
-
-    new_job_submission = create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        job_submission_description,
-        cluster_name="dummy-cluster",
-    )
-    assert new_job_submission == JobSubmissionResponse(**job_submission_data)
+class TestJobSubmissionsGetRequestData:
+    def test_handle_request_data__on_site(self, dummy_context, attach_persona):
+        attach_persona("dummy@dummy.com")
 
+        dummy_data = dict(
+            job_script_id=1,
+            name="test",
+            execution_directory=pathlib.Path("/some/fake/path"),
+            sbatch_arguments=["--partition=debug", "--time=1:00:00"],
+            description="test description",
+            cluster_name="test-cluster",
+        )
 
-def test_create_job_submission__with_explicit_cluster_name(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        submission_handler = OnsiteJobSubmission(jg_ctx=dummy_context, download=True, **dummy_data)
 
-    job_script_id = job_submission_data["job_script_id"]
+        # simulate process_submission by setting slurm_job_id
+        dummy_data["slurm_job_id"] = 1234
+        submission_handler.slurm_job_id = dummy_data["slurm_job_id"]
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+        actual_request_data = submission_handler.get_request_data()
+        expected_request_data = JobSubmissionCreateRequestData.parse_obj(dummy_data)
 
-    attach_persona("dummy@dummy.com")
-    seed_clusters("other-cluster")
-
-    new_job_submission = create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        description=job_submission_description,
-        cluster_name="other-cluster",
-    )
-    assert new_job_submission == JobSubmissionResponse(**job_submission_data)
+        assert actual_request_data == expected_request_data
 
-    assert json.loads(create_job_submission_route.calls.last.request.content)["client_id"] == "other-cluster"
+    def test_handle_request_data__remote(self, dummy_context, attach_persona, dummy_domain, respx_mock):
+        attach_persona("dummy@dummy.com")
 
+        dummy_data = dict(
+            job_script_id=1,
+            name="test",
+            execution_directory=pathlib.Path("/some/fake/path"),
+            sbatch_arguments=["--partition=debug", "--time=1:00:00"],
+            description="test description",
+            cluster_name="test-cluster",
+        )
 
-def test_create_job_submission__with_default_cluster_name(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-    tweak_settings,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        submission_handler = RemoteJobSubmission(jg_ctx=dummy_context, download=True, **dummy_data)
 
-    job_script_id = job_submission_data["job_script_id"]
+        actual_request_data = submission_handler.get_request_data()
+        expected_request_data = JobSubmissionCreateRequestData.parse_obj(dummy_data)
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+        assert actual_request_data == expected_request_data
 
-    attach_persona("dummy@dummy.com")
-    seed_clusters("other-cluster")
-
-    with tweak_settings(DEFAULT_CLUSTER_NAME="default-cluster"):
-        new_job_submission = create_job_submission(
-            dummy_context,
-            job_script_id,
-            job_submission_name,
-            description=job_submission_description,
+
+@pytest.mark.parametrize("submission_cls", [OnsiteJobSubmission, RemoteJobSubmission])
+class TestJobSubmissionsMakePostRequest:
+    def test_post_request__success(
+        self, dummy_context, attach_persona, submission_cls, dummy_job_submission_data, respx_mock, dummy_domain
+    ):
+        attach_persona("dummy@dummy.com")
+        job_submission_data = dummy_job_submission_data[0]
+
+        submission_handler = submission_cls(
+            jg_ctx=dummy_context,
+            job_script_id=job_submission_data["job_script_id"],
+            name=job_submission_data["name"],
+            cluster_name="test-cluster",
         )
-        assert new_job_submission == JobSubmissionResponse(**job_submission_data)
 
-    assert json.loads(create_job_submission_route.calls.last.request.content)["client_id"] == "default-cluster"
+        create_job_submission_data = JobSubmissionCreateRequestData.parse_obj(job_submission_data)
+        create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
+        create_job_submission_route.mock(
+            return_value=httpx.Response(
+                httpx.codes.CREATED,
+                json=job_submission_data,
+            ),
+        )
 
+        actual_response = submission_handler.make_post_request(create_job_submission_data)
+        expected_response = JobSubmissionResponse.parse_obj(job_submission_data)
 
-def test_create_job_submission__throws_exception_with_no_explicit_or_default_cluster_name(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        assert actual_response == expected_response
 
-    job_script_id = job_submission_data["job_script_id"]
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+class TestJobSubmissionsProcessSubmissions:
+    def test_process_submission__remote_download(self, dummy_context, mocker, attach_persona):
+        attach_persona("dummy@dummy.com")
+        submission_handler = RemoteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+            download=True,
+        )
+        mocked_download_job_script_files = mocker.patch(
+            "jobbergate_cli.subapps.job_submissions.tools.download_job_script_files"
+        )
+        submission_handler.process_submission()
+        mocked_download_job_script_files.assert_called_once_with(
+            submission_handler.job_script_id, submission_handler.jg_ctx, submission_handler.execution_directory
+        )
 
-    attach_persona("dummy@dummy.com")
+    def test_process_submission__no_remote_download(self, dummy_context, mocker, attach_persona):
+        attach_persona("dummy@dummy.com")
+        submission_handler = RemoteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+            download=False,
+        )
+        mocked_download_job_script_files = mocker.patch(
+            "jobbergate_cli.subapps.job_submissions.tools.download_job_script_files"
+        )
+        submission_handler.process_submission()
+        mocked_download_job_script_files.assert_not_called()
 
-    with pytest.raises(Abort, match="unknown cluster"):
-        create_job_submission(
-            dummy_context,
-            job_script_id,
-            job_submission_name,
-            description=job_submission_description,
+    def test_process_submission__on_site_abort_if_sbatch_path_is_unset(
+        self, dummy_context, attach_persona, tweak_settings
+    ):
+        attach_persona("dummy@dummy.com")
+        submission_handler = OnsiteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
         )
 
+        with (
+            tweak_settings(SBATCH_PATH=None),
+            pytest.raises(Abort, match="SBATCH_PATH most be set for onsite submissions"),
+        ):
+            submission_handler.process_submission()
 
-def test_create_job_submission__with_explicit_execution_dir(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+    class DummyFile:
+        @property
+        def file_type(self):
+            return "NOT-ENTRYPOINT"
 
-    job_script_id = job_submission_data["job_script_id"]
+    @pytest.mark.parametrize("job_script_files", [[], [DummyFile(), DummyFile()]])
+    def test_process_submission__on_site_abort_if_not_exact_one_entrypoint_file_is_found(
+        self, job_script_files, dummy_context, attach_persona, tweak_settings, mocker, tmp_path
+    ):
+        attach_persona("dummy@dummy.com")
+        submission_handler = OnsiteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+        )
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+        mocker.patch(
+            "jobbergate_cli.subapps.job_submissions.tools.download_job_script_files", return_value=job_script_files
+        )
 
-    attach_persona("dummy@dummy.com")
-    seed_clusters("dummy-cluster")
-
-    create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        job_submission_description,
-        cluster_name="dummy-cluster",
-        execution_directory=pathlib.Path("/some/fake/path"),
-    )
-    payload = json.loads(create_job_submission_route.calls.last.request.content)
-    assert payload["execution_directory"] == "/some/fake/path"
-
-    create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        job_submission_description,
-        cluster_name="dummy-cluster",
-        execution_directory=pathlib.Path("./some/relative/path"),
-    )
-    payload = json.loads(create_job_submission_route.calls.last.request.content)
-    assert payload["execution_directory"] == str(pathlib.Path.cwd() / "./some/relative/path")
+        with (
+            tweak_settings(SBATCH_PATH=tmp_path),
+            pytest.raises(Abort, match="There should be exactly one entrypoint file in the parent job script"),
+        ):
+            submission_handler.process_submission()
 
+    @pytest.mark.parametrize("download", [True, False])
+    def test_process_submission__on_site_success(
+        self, download, dummy_context, attach_persona, tweak_settings, mocker, tmp_path, dummy_job_script_data
+    ):
+        attach_persona("dummy@dummy.com")
 
-def test_create_job_submission__with_default_execution_dir(
-    mocker,
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        job_script_data = JobScriptResponse.parse_obj(dummy_job_script_data[0])
 
-    job_script_id = job_submission_data["job_script_id"]
+        submission_handler = OnsiteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+            download=download,
+            execution_directory=tmp_path,
+        )
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+        mocked_download_job_script_files = mocker.patch(
+            "jobbergate_cli.subapps.job_submissions.tools.download_job_script_files", return_value=job_script_data.files
+        )
+        mocked_inject_sbatch_params = mocker.patch.object(submission_handler, "inject_sbatch_params")
 
-    attach_persona("dummy@dummy.com")
-    seed_clusters("dummy-cluster")
+        mocked_sbatch = mock.MagicMock()
+        mocked_sbatch.submit_job = lambda *args, **kwargs: 13
+        mocker.patch("jobbergate_cli.subapps.job_submissions.tools.SubmissionHandler", return_value=mocked_sbatch)
 
-    mocker.patch("jobbergate_cli.subapps.job_submissions.tools.Path.cwd", return_value=pathlib.Path("/some/fake/path"))
+        with tweak_settings(SBATCH_PATH=tmp_path):
+            submission_handler.process_submission()
+
+        assert submission_handler.slurm_job_id == 13
+
+        # files are downloaded anyway for on-site submissions
+        mocked_download_job_script_files.assert_called_once_with(1, dummy_context, tmp_path)
+        assert mocked_inject_sbatch_params.call_count == 1
+
+    def test_inject_sbatch_params__on_site(self, mocker, attach_persona, dummy_context, tmp_path):
+        attach_persona("dummy@dummy.com")
+        submission_handler = OnsiteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+            execution_directory=tmp_path,
+            sbatch_arguments=["--partition=debug", "--time=1:00:00"],
+        )
 
-    create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        job_submission_description,
-        cluster_name="dummy-cluster",
-        execution_directory=None,
-    )
-    payload = json.loads(create_job_submission_route.calls.last.request.content)
-    assert payload["execution_directory"] == "/some/fake/path"
+        job_script_path = tmp_path / "entrypoint.sh"
 
+        job_script_path.write_text("original content")
 
-def test_create_job_submission__in_multitenancy_mode_with_org_id(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        mocked_inject_sbatch_params = mocker.patch(
+            "jobbergate_cli.subapps.job_submissions.tools.inject_sbatch_params", return_value="inject_sbatch_params"
+        )
 
-    job_script_id = job_submission_data["job_script_id"]
+        submission_handler.inject_sbatch_params(job_script_path)
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+        mocked_inject_sbatch_params.assert_called_once_with(
+            "original content", ["--partition=debug", "--time=1:00:00"], "Injected at submission time by Jobbergate CLI"
+        )
+        assert job_script_path.read_text() == "inject_sbatch_params"
+
+    def test_skip_inject_sbatch_params__on_site(self, mocker, attach_persona, dummy_context, tmp_path):
+        attach_persona("dummy@dummy.com")
+        submission_handler = OnsiteJobSubmission(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+            execution_directory=tmp_path,
+            sbatch_arguments=[],
+        )
 
-    attach_persona("dummy@dummy.com", organization_id="some-organization")
-    seed_clusters("other-cluster-some-organization")
+        job_script_path = tmp_path / "entrypoint.sh"
 
-    new_job_submission = create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        description=job_submission_description,
-        cluster_name="other-cluster",
-    )
-    assert new_job_submission == JobSubmissionResponse(**job_submission_data)
+        job_script_path.write_text("original content")
 
-    assert (
-        json.loads(create_job_submission_route.calls.last.request.content)["client_id"]
-        == "other-cluster-some-organization"
-    )
+        mocked_inject_sbatch_params = mocker.patch("jobbergate_cli.subapps.job_submissions.tools.inject_sbatch_params")
 
+        submission_handler.inject_sbatch_params(job_script_path)
 
-def test_create_job_submission__in_multitenancy_mode_with_org_id_and_cluster_name_already_has_org_id(
-    respx_mock,
-    dummy_job_submission_data,
-    dummy_domain,
-    dummy_context,
-    attach_persona,
-    seed_clusters,
-):
-    job_submission_data = dummy_job_submission_data[0]
-    job_submission_name = job_submission_data["name"]
-    job_submission_description = job_submission_data["description"]
+        assert mocked_inject_sbatch_params.call_count == 0
+        assert job_script_path.read_text() == "original content"
 
-    job_script_id = job_submission_data["job_script_id"]
 
-    create_job_submission_route = respx_mock.post(f"{dummy_domain}/jobbergate/job-submissions")
-    create_job_submission_route.mock(
-        return_value=httpx.Response(
-            httpx.codes.CREATED,
-            json=job_submission_data,
-        ),
-    )
+@pytest.mark.parametrize("submission_cls", [OnsiteJobSubmission, RemoteJobSubmission])
+class TestJobSubmissionsRun:
+    def test_run__success(self, dummy_context, attach_persona, submission_cls, mocker):
+        attach_persona("dummy@dummy.com")
+        submission_handler = submission_cls(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+        )
 
-    attach_persona("dummy@dummy.com", organization_id="some-organization")
-    seed_clusters("other-cluster-some-organization")
+        mocked_process_submission = mocker.patch.object(submission_handler, "process_submission")
+        mocked_get_request_data = mocker.patch.object(
+            submission_handler, "get_request_data", return_value="request_data"
+        )
+        mocked_make_post_request = mocker.patch.object(submission_handler, "make_post_request", return_value="response")
 
-    new_job_submission = create_job_submission(
-        dummy_context,
-        job_script_id,
-        job_submission_name,
-        description=job_submission_description,
-        cluster_name="other-cluster-some-organization",
-    )
-    assert new_job_submission == JobSubmissionResponse(**job_submission_data)
+        actual_response = submission_handler.run()
 
-    assert (
-        json.loads(create_job_submission_route.calls.last.request.content)["client_id"]
-        == "other-cluster-some-organization"
-    )
+        assert actual_response == "response"
+        mocked_process_submission.assert_called_once()
+        mocked_get_request_data.assert_called_once()
+        mocked_make_post_request.assert_called_once_with("request_data")
+
+
+@pytest.mark.parametrize(
+    "submission_cls,sbatch_path", [[OnsiteJobSubmission, "/usr/bin/sbatch"], [RemoteJobSubmission, None]]
+)
+def test_job_submissions_factory(submission_cls, sbatch_path, attach_persona, dummy_context, tweak_settings):
+    attach_persona("dummy@dummy.com")
+    with tweak_settings(SBATCH_PATH=sbatch_path):
+        submission_handler = job_submissions_factory(
+            jg_ctx=dummy_context,
+            job_script_id=1,
+            name="test",
+            cluster_name="test-cluster",
+            download=True,
+        )
+    assert isinstance(submission_handler, submission_cls)
+    assert submission_handler.jg_ctx == dummy_context
+    assert submission_handler.job_script_id == 1
+    assert submission_handler.name == "test"
+    assert submission_handler.cluster_name == "test-cluster"
+    assert submission_handler.download is True
 
 
 def test_fetch_job_submission_data__success__using_id(
@@ -339,60 +406,3 @@ def test_fetch_job_submission_data__success__using_id(
     assert fetch_route.called
     assert result == JobSubmissionResponse(**job_submission_data)
     assert result.report_message == job_submission_data.get("report_message")
-
-
-class TestSbatchRun:
-    def test_valid_arguments_returns_slurm_id(self, mocker, tweak_settings, tmp_path):
-        mocked_popen = mocker.MagicMock()
-        mocker.patch("jobbergate_cli.subapps.job_submissions.tools.Popen", return_value=mocked_popen)
-        mocked_popen.communicate.return_value = (b"Submitted batch job 123", b"")
-        mocked_popen.returncode = 0
-
-        with tweak_settings(SBATCH_PATH=tmp_path):
-            slurm_id = sbatch_run("filename.sh")
-
-        assert slurm_id == 123
-
-    def test_raises_abort_if_sbatch_command_returns_non_zero_exit_code(
-        self,
-        mocker,
-        tweak_settings,
-        tmp_path,
-    ):
-        mocked_popen = mocker.MagicMock()
-        mocker.patch("jobbergate_cli.subapps.job_submissions.tools.Popen", return_value=mocked_popen)
-        mocked_popen.communicate.return_value = (b"", b"Error: Invalid argument")
-        mocked_popen.returncode = 1
-
-        with tweak_settings(SBATCH_PATH=tmp_path):
-            with pytest.raises(
-                Abort,
-                match="^Failed to execute submission with error",
-            ):
-                sbatch_run("filename.sh")
-
-    def test_raises_abort_if_slurm_job_id_cannot_be_parsed_from_output(
-        self,
-        mocker,
-        tweak_settings,
-        tmp_path,
-    ):
-        mocked_popen = mocker.MagicMock()
-        mocker.patch("jobbergate_cli.subapps.job_submissions.tools.Popen", return_value=mocked_popen)
-        mocked_popen.communicate.return_value = (b"Invalid output", b"")
-        mocked_popen.returncode = 0
-
-        with tweak_settings(SBATCH_PATH=tmp_path):
-            with pytest.raises(
-                Abort,
-                match="^Failed to parse slurm job id from output=",
-            ):
-                sbatch_run("filename.sh")
-
-    def test_raises_abort_if_sbatch_path_not_set_or_does_not_exist(self, tweak_settings, tmp_path):
-        with tweak_settings(SBATCH_PATH=tmp_path / "nonexistent"):
-            with pytest.raises(
-                Abort,
-                match="^The path to the sbatch executable is not set or does not exist",
-            ):
-                sbatch_run("filename.sh")
diff --git a/jobbergate-cli/tests/test_config.py b/jobbergate-cli/tests/test_config.py
index 6aad4264..7b096a28 100644
--- a/jobbergate-cli/tests/test_config.py
+++ b/jobbergate-cli/tests/test_config.py
@@ -36,3 +36,9 @@ def test_cache_dir__expands_user_and_resolves():
     settings = Settings(JOBBERGATE_CACHE_DIR="~/.jobbergate-cli-prod")
     assert settings.JOBBERGATE_CACHE_DIR == Path.home() / ".jobbergate-cli-prod"
     assert settings.JOBBERGATE_CACHE_DIR.is_absolute()
+
+
+@pytest.mark.parametrize("sbatch_path, is_onsite_mode", [[None, False], ["/usr/bin/sbatch", True]])
+def test_is_onsite_mode__is_true_when_sbatch_path_is_set(sbatch_path, is_onsite_mode):
+    settings = Settings(SBATCH_PATH=sbatch_path)
+    assert settings.is_onsite_mode == is_onsite_mode
diff --git a/jobbergate-core/tests/auth/test_handler.py b/jobbergate-core/tests/auth/test_handler.py
index c4f88e03..146e1b25 100644
--- a/jobbergate-core/tests/auth/test_handler.py
+++ b/jobbergate-core/tests/auth/test_handler.py
@@ -1,6 +1,7 @@
 """
 Test the utilities for handling auth in Jobbergate.
 """
+
 from unittest import mock
 
 import httpx
diff --git a/jobbergate-core/tests/auth/test_token.py b/jobbergate-core/tests/auth/test_token.py
index 5421311f..cb25a178 100644
--- a/jobbergate-core/tests/auth/test_token.py
+++ b/jobbergate-core/tests/auth/test_token.py
@@ -1,6 +1,7 @@
 """
 Test the utilities for handling auth in Jobbergate.
 """
+
 import pytest
 
 from jobbergate_core.auth.token import Token, TokenError, TokenType
diff --git a/jobbergate-core/tests/test_version.py b/jobbergate-core/tests/test_version.py
index 96b59c69..2aa7e2b1 100644
--- a/jobbergate-core/tests/test_version.py
+++ b/jobbergate-core/tests/test_version.py
@@ -1,6 +1,7 @@
 """
 Test the module version.
 """
+
 import toml
 
 from jobbergate_core import __version__
diff --git a/jobbergate-core/tests/tools/__init__.py b/jobbergate-core/tests/tools/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/jobbergate-core/tests/tools/test_sbatch.py b/jobbergate-core/tests/tools/test_sbatch.py
new file mode 100644
index 00000000..24fc2c5d
--- /dev/null
+++ b/jobbergate-core/tests/tools/test_sbatch.py
@@ -0,0 +1,279 @@
+import json
+import subprocess
+from pathlib import Path
+from tempfile import TemporaryDirectory
+from textwrap import dedent
+
+import pytest
+
+from jobbergate_core.tools.sbatch import InfoHandler, SubmissionHandler, inject_sbatch_params
+
+
+@pytest.fixture()
+def sbatch_path(tmp_path):
+    path = tmp_path / "sbatch"
+    path.write_text("#!/bin/bash\n")
+    return path
+
+
+@pytest.fixture()
+def scontrol_path(tmp_path):
+    path = tmp_path / "scontrol"
+    path.write_text("#!/bin/bash\n")
+    return path
+
+
+class TestSubmissionHandler:
+    @pytest.mark.parametrize(
+        "sbatch_output, expected_result",
+        [
+            ("123", {"id": "123", "cluster_name": None}),
+            ("123,cluster", {"id": "123", "cluster_name": "cluster"}),
+        ],
+    )
+    def test_parse_sbatch_parser(self, sbatch_output, expected_result):
+        actual_result = SubmissionHandler.sbatch_output_parser.match(sbatch_output).groupdict()
+        assert actual_result == expected_result
+
+    def test_run__success(self, mocker, sbatch_path, tmp_path):
+        response = subprocess.CompletedProcess(args=[], stdout="123", returncode=0)
+        mocked_run = mocker.patch("jobbergate_core.tools.sbatch.subprocess.run", return_value=response)
+
+        def dummy_preexec_fn():
+            pass
+
+        sbatch_handler = SubmissionHandler(
+            sbatch_path=sbatch_path,
+            submission_directory=tmp_path,
+            preexec_fn=dummy_preexec_fn,
+        )
+
+        job_script_path = tmp_path / "file.sh"
+
+        assert sbatch_handler.submit_job(job_script_path) == 123
+        mocked_run.assert_called_once_with(
+            (
+                sbatch_path.as_posix(),
+                "--parsable",
+                job_script_path.as_posix(),
+            ),
+            preexec_fn=dummy_preexec_fn,
+            check=True,
+            shell=False,
+            cwd=tmp_path,
+            capture_output=True,
+            text=True,
+        )
+
+    def test_run__fail_on_sbatch_error(self, mocker, sbatch_path, tmp_path):
+        mocker.patch(
+            "jobbergate_core.tools.sbatch.subprocess.run",
+            side_effect=subprocess.CalledProcessError(1, "sbatch", stderr="Error: Invalid argument"),
+        )
+
+        sbatch_handler = SubmissionHandler(sbatch_path=sbatch_path, submission_directory=tmp_path)
+        job_script_path = tmp_path / "file.sh"
+
+        with pytest.raises(
+            RuntimeError,
+            match="^Failed to run command with code 1: Error: Invalid argument",
+        ):
+            sbatch_handler.submit_job(job_script_path)
+
+    @pytest.mark.parametrize("stdout", ["", "not-a-number"])
+    def test_run__fail_on_unparsable_output(self, mocker, stdout, sbatch_path, tmp_path):
+        response = subprocess.CompletedProcess(args=[], stdout=stdout, returncode=0)
+        mocker.patch("jobbergate_core.tools.sbatch.subprocess.run", return_value=response)
+
+        sbatch_handler = SubmissionHandler(sbatch_path=sbatch_path, submission_directory=tmp_path)
+
+        job_script_path = tmp_path / "file.sh"
+
+        with pytest.raises(
+            RuntimeError,
+            match="^Failed to parse slurm job id from",
+        ):
+            sbatch_handler.submit_job(job_script_path)
+
+    def test_file_to_submission__success(self, sbatch_path, tmp_path):
+        sbatch_handler = SubmissionHandler(
+            sbatch_path=sbatch_path,
+            submission_directory=tmp_path,
+        )
+
+        with TemporaryDirectory() as temp_dir:
+            source_file = Path(temp_dir) / "file.txt"
+            file_content = b"test"
+            source_file.write_bytes(file_content)
+            destination_file = sbatch_handler.copy_file_to_submission_directory(source_file)
+            assert destination_file == tmp_path / source_file.name
+            assert destination_file.read_bytes() == file_content
+
+
+class TestInfoHandler:
+    def test_get_job_info__success(self, mocker, scontrol_path):
+        job_info = {"id": 123, "cluster_name": "cluster", "status": "running", "user": "test-user"}
+        response_data = json.dumps({"jobs": [job_info]})
+        response = subprocess.CompletedProcess(args=[], stdout=response_data, returncode=0)
+        mocked_run = mocker.patch("jobbergate_core.tools.sbatch.subprocess.run", return_value=response)
+
+        sbatch_handler = InfoHandler(scontrol_path=scontrol_path)
+
+        assert sbatch_handler.get_job_info(123) == job_info
+        mocked_run.assert_called_once_with(
+            (
+                scontrol_path.as_posix(),
+                "show",
+                "job",
+                "123",
+                "--json",
+            ),
+            preexec_fn=None,
+            check=True,
+            shell=False,
+            capture_output=True,
+            text=True,
+        )
+
+    def test_get_job_info__failed_to_parse(self, mocker, scontrol_path):
+        response_data = json.dumps({"foo": "bar"})
+        response = subprocess.CompletedProcess(args=[], stdout=response_data, returncode=0)
+        mocked_run = mocker.patch("jobbergate_core.tools.sbatch.subprocess.run", return_value=response)
+
+        sbatch_handler = InfoHandler(scontrol_path=scontrol_path)
+
+        with pytest.raises(RuntimeError, match="^Failed to parse job info from"):
+            sbatch_handler.get_job_info(123)
+
+        mocked_run.assert_called_once_with(
+            (
+                scontrol_path.as_posix(),
+                "show",
+                "job",
+                "123",
+                "--json",
+            ),
+            preexec_fn=None,
+            check=True,
+            shell=False,
+            capture_output=True,
+            text=True,
+        )
+
+    def test_get_job_info__not_fount(self, mocker, sbatch_path, scontrol_path, tmp_path):
+        response_data = json.dumps({"jobs": []})
+        response = subprocess.CompletedProcess(args=[], stdout=response_data, returncode=0)
+        mocked_run = mocker.patch("jobbergate_core.tools.sbatch.subprocess.run", return_value=response)
+
+        sbatch_handler = InfoHandler(scontrol_path=scontrol_path)
+        with pytest.raises(RuntimeError, match="^Job not fount: 123"):
+            sbatch_handler.get_job_info(123)
+        mocked_run.assert_called_once_with(
+            (
+                scontrol_path.as_posix(),
+                "show",
+                "job",
+                "123",
+                "--json",
+            ),
+            preexec_fn=None,
+            check=True,
+            shell=False,
+            capture_output=True,
+            text=True,
+        )
+
+
+class TestInjectSbatchParameters:
+    def test_with_header(self):
+        sbatch_params = ["--comment=some_comment", "--nice=-1", "-N 10"]
+
+        job_script_data_as_string = dedent(
+            """
+            #!/bin/bash
+
+            #SBATCH --job-name=rats
+            #SBATCH --partition=debug
+            #SBATCH --output=sample-%j.out
+
+            echo $SLURM_TASKS_PER_NODE
+            echo $SLURM_SUBMIT_DIR
+            """
+        )
+
+        expected_result = dedent(
+            """
+            #!/bin/bash
+
+            #SBATCH --job-name=rats
+            #SBATCH --partition=debug
+            #SBATCH --output=sample-%j.out
+
+            # Sbatch params injected at rendering time
+            #SBATCH --comment=some_comment
+            #SBATCH --nice=-1
+            #SBATCH -N 10
+
+            echo $SLURM_TASKS_PER_NODE
+            echo $SLURM_SUBMIT_DIR
+            """
+        )
+
+        actual_result = inject_sbatch_params(
+            job_script_data_as_string, sbatch_params, "Sbatch params injected at rendering time"
+        )
+        assert actual_result == expected_result
+
+    def test_inject_sbatch_params__no_sbatch_flag(self):
+        sbatch_params = ["--comment=some_comment", "--nice=-1", "-N 10"]
+
+        job_script_data_as_string = dedent(
+            """
+            #!/bin/bash
+
+            echo $SLURM_TASKS_PER_NODE
+            echo $SLURM_SUBMIT_DIR
+            """
+        )
+
+        expected_result = dedent(
+            """
+            #!/bin/bash
+
+            #SBATCH --comment=some_comment
+            #SBATCH --nice=-1
+            #SBATCH -N 10
+
+            echo $SLURM_TASKS_PER_NODE
+            echo $SLURM_SUBMIT_DIR
+            """
+        )
+
+        actual_result = inject_sbatch_params(job_script_data_as_string, sbatch_params)
+        assert actual_result == expected_result
+
+    def test_inject_sbatch_params__empty_list(self):
+        sbatch_params = []
+
+        job_script_data_as_string = dedent(
+            """
+            #!/bin/bash
+
+            echo $SLURM_TASKS_PER_NODE
+            echo $SLURM_SUBMIT_DIR
+            """
+        )
+
+        expected_result = dedent(
+            """
+            #!/bin/bash
+
+            echo $SLURM_TASKS_PER_NODE
+            echo $SLURM_SUBMIT_DIR
+            """
+        )
+
+        actual_result = inject_sbatch_params(
+            job_script_data_as_string, sbatch_params, "Sbatch params injected at rendering time"
+        )
+        assert actual_result == expected_result