← Blog

runwisp-jobkit: a shell-free harness for RunWisp jobs

A Python framework for packaging jobs that run on RunWisp, the self-hosted cron system for Mac.

runwisptoolingpythonsdk

What RunWisp is

RunWisp is an open-source, self-hosted cron job manager and process supervisor. It ships as a single static Go binary with no runtime dependencies.

You trade the usual crond and supervisord setup for one runwisp.toml, plus a web dashboard, a terminal UI, and a REST API.

Each run keeps an exit code, duration, timestamps, and full stdout/stderr. Full docs are at docs.runwisp.com.

What RunWisp does well

RunWisp is built for operations: schedules, retries, timeouts, retention, notifications, overlap policy, and live log streaming.

You point a task at a command. RunWisp runs it on a cron (or keeps a service alive), captures the output, and stores history you can search later.

That layer is the product. Packaging jobs is not.

RunWisp owns schedules and history. runwisp-jobkit owns the package contract, doctor, and shell-free run.

The gap this project fills

After a while the scheduler stops being the hard part. The hard part is knowing what “the job” actually is.

Shell-only packaging picks up silent assumptions: working directory, required env vars, files that must exist, argv that only half lives in a README. Sometimes that knowledge is only in a teammate’s head.

You find those gaps when the job fails at 2 AM. RunWisp will show the logs. It will not tell you the package was incomplete before the run started.

RunWisp watches execution. Something else has to own a job contract you can check ahead of time.

Why runwisp-jobkit exists

I built runwisp-jobkit so a RunWisp job is a filesystem package instead of a fragile shell one-liner.

Shell one-liner with silent assumptions versus a job directory with job.toml and run.py.

A job is a directory: a job.toml manifest plus the files the command needs. Humans and agents share one convention. RunWisp stays the place schedules and history live.

The harness validates the package, then runs the command without a shell. No string splicing, no surprise metacharacter expansion. Arguments land as argv.

What it solves

The failures I kept hitting once jobs left a single laptop:

  1. Incomplete packages: missing files and blank required env vars fail in doctor before anything executes.
  2. Implicit contracts: cwd, argv, env, and required files live in one typed manifest instead of tribal knowledge.
  3. Shell glue drift: Python, TypeScript/Bun, Rust, and Shell packages share one command model instead of each growing its own wrapper script.

Why this shape

doctor never runs the job. It only does passive preflight: manifest and confined paths, required env names present and nonblank, required files readable, and executable availability. It does not prove the job will succeed at runtime. Catching contract gaps early beats debugging them in production.

Policy stays out of the harness on purpose. Schedules, secrets, retries, and notifications stay with RunWisp and the deployment environment, so the package stays portable and reviewable.

Shell-free exec keeps process behavior honest. After process replacement, stdout, stderr, exit codes, and signals come from the job, which is what RunWisp’s observability is meant to record.

Install

runwisp-jobkit 0.1.0 is on PyPI. Source, releases, and authoring docs are at github.com/engineersamuel/runwisp-jobkit.

uv tool install --python 3.14 runwisp-jobkit
runwisp-job --help

From a local checkout at the repo root:

uv tool install --force --python 3.14 .

Wire a RunWisp task with an explicit filesystem pointer, for example:

[tasks.example-job]
cron = "0 6 * * *"
run = "runwisp-job run /path/to/jobs/example"

RunWisp still owns schedule and retention. The package owns behavior and declared inputs.

The contract: job.toml

Anatomy of a job package: job.toml fields for identity, how it runs, and required inputs.

Every job directory has a manifest. Complete example with all seven fields:

schema = 1
id = "example-job"
kind = "command"
cwd = "."
argv = ["python", "run.py"]
required_env = ["EXAMPLE_MESSAGE"]
required_files = ["run.py"]
Field Role
schema Manifest version (1 today)
id Nonempty diagnostic label for doctor and error messages; not a registry key
kind Execution kind (command today)
cwd Working directory relative to the package
argv Command and arguments to exec
required_env Env vars that must be present and nonblank
required_files Files that must exist and be readable

Unknown fields are rejected, so a typo fails closed instead of quietly changing behavior.

cwd and required_files must stay inside the job directory. Absolute paths, parent traversal, and symlink escapes are rejected for those typed paths. The harness is not a sandbox for job code.

CLI: doctor, then run

JOB_DIR goes through doctor validation, then run execs the job and exit codes flow to RunWisp history.

runwisp-job doctor JOB_DIR
runwisp-job run JOB_DIR [ARG ...]
  • doctor runs the same passive preflight without executing the job.
  • run appends each supplied argument to the manifest command unchanged, then replaces the harness process with the job process.

That split keeps “does this package pass preflight?” separate from “execute it.”

Everything after JOB_DIR on run is forwarded as argv, so RunWisp parameters can pass through:

runwisp-job run /path/to/job -- --dry-run

There is no shell parsing in the harness. Quotes and metacharacters have no special meaning unless the job itself puts a shell in argv.

Minimal Python job

The repository’s Python template is a tiny package like this:

python/
  job.toml
  run.py
schema = 1
id = "python-example"
kind = "command"
cwd = "."
argv = ["uv", "run", "--script", "run.py"]
required_env = ["RUNWISP_EXAMPLE_MESSAGE"]
required_files = ["run.py"]
# /// script
# requires-python = ">=3.14"
# ///

import argparse
import os

parser = argparse.ArgumentParser()
parser.add_argument("--dry-run", action="store_true")
arguments = parser.parse_args()
message = os.environ["RUNWISP_EXAMPLE_MESSAGE"]

if arguments.dry_run:
    print(f"dry-run: {message}")
else:
    print(message)

Validate and run:

export RUNWISP_EXAMPLE_MESSAGE="hello from runwisp"
runwisp-job doctor ./python
runwisp-job run ./python -- --dry-run

doctor should pass only when the env var is set, run.py is present and readable, and uv is available on PATH. That is how you catch an incomplete package before the schedule fires.

Language templates

The repository includes templates you can run immediately:

  • Python
  • TypeScript (Bun)
  • Rust
  • Shell

One command model covers all of them. Language choice stays inside the package; the harness only sees argv.

There is also a sanitized nightly news example based on a production-style job. Some private dependencies are left out on purpose, so treat it as a design reference more than a one-click demo.

What the harness does not do

I kept the surface small on purpose.

The harness does not:

  • Install job dependencies
  • Discover or register jobs
  • Store secrets
  • Manage RunWisp schedules, retries, or notifications
  • Merge packages into runwisp.toml

Packages and their deployment environment own those concerns. RunWisp is still the operational control plane. jobkit is the package contract and runner.

Security boundary

Path confinement applies only to the manifest cwd and declared required files. Job code still has the filesystem, network, and environment access of its OS account.

Authors install runtimes and dependencies, declare required inputs, validate forwarded args, and return useful exit codes. The harness fails closed on the contract; it is not a jail.

Exit codes

  • 2: harness response for CLI usage or job configuration failures before execution; a running job can also exit 2
  • 126: executable became non-runnable or had an invalid executable format at process replacement
  • 127: executable missing at process replacement

Other job exit codes pass through unchanged, so RunWisp history matches the real process.

Try it

If your job contracts still live in shell glue, start with one package:

  1. Drop a job.toml next to the entrypoint
  2. Declare required_env and required_files honestly
  3. Run runwisp-job doctor until it is clean
  4. Point a RunWisp task at runwisp-job run /path/to/job
  5. Trigger once from the UI or TUI and compare the failure mode to bare shell

More detail: project page, GitHub repo, authoring docs. The v0.1.0 release ships one wheel and one sdist.

Install with uv tool install --python 3.14 runwisp-jobkit, doctor a real job directory, and write down every assumption that used to be invisible. That list is why I wrote this.