The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Jobd listing page.
A self-hostable, GPU-aware job broker for your own machines — with native MCP/agent integration.
Like task-spooler or pueue, but across all your machines — and VRAM-aware.
You have a couple of boxes with GPUs — a workstation, a server, maybe a laptop — wired together over Tailscale or a LAN. You want to fire off training runs, data pipelines, and long batch jobs from anywhere, have them land on whichever machine actually has the VRAM free, survive across sessions, and get preempted cleanly when something more important shows up. You don't have a cloud, a Kubernetes cluster, or a Slurm install, and you don't want one.
jobd is that missing piece: a small broker that turns a handful of personal machines into a single queue — and an LLM agent can drive it directly.
Most schedulers assume a datacenter. The lightweight ones that don't (a bare nohup, a tmux session, an ssh-and-pray script) give you nothing: no queue, no VRAM-aware routing, no preemption, no record of what ran where. jobd fills the gap between "ssh in and run it" and "stand up Slurm":
SIGTERM, the workload gets a grace window to checkpoint, then SIGKILL. A preempted job reaches a terminal preempted state with a durable checkpoint to resume from — it isn't silently re-run. (See docs/preemption.md.)| Tool | What it gives you | Why jobd instead |
|---|---|---|
nohup / tmux / ssh-and-pray | Runs a command on one box | No queue, no VRAM-aware routing, no preemption, no record of what ran where |
| task-spooler | A real job queue — on a single machine | jobd queues across all your machines and routes by live VRAM/CPU fit |
| Pueue | The best single-machine command queue daemon | Pueue's own README declares distributed execution out of scope — jobd is that missing layer, plus GPU awareness |
| HyperQueue | Multi-machine task scheduling with HPC roots, single binary | HQ counts GPUs but doesn't track VRAM, and has no preemption/checkpoint contract or agent interface |
| Slurm | Datacenter-grade scheduling | Heavy to stand up and operate for 2–3 personal boxes; jobd is one process + a poller per host |
| SkyPilot / Modal / dstack | Provision and run on clouds + your own machines | SkyPilot's "existing machines" mode installs a k3s cluster on your boxes; dstack wants Docker + passwordless sudo on every host. jobd is one process + a poller — no containers, no sudo, no K8s |
| Ray | A distributed-compute framework | jobd is a job queue, not a programming model — submit any command, no code changes, GPU-fit routing built in |
Closest in spirit are Pueue and task-spooler (single-machine by design) and HyperQueue (multi-machine, HPC-shaped). jobd's niche is the 2–5-GPU homelab: multi-machine live VRAM-fit routing + preempt/checkpoint + a native MCP interface — a combination none of the above offers — with nothing heavier than a Python process per host.
Workers poll the broker (pull model — no inbound connection to a worker); the broker matches each job against live capacity and hands it back on the poll. One broker process, one poller per host.
shell=False, no shell-injection surface), streams logs back, and honors preemption signals.job CLI, the jobd-mcp MCP server, or anything that speaks the HTTP API.Requires Python ≥ 3.11. Everything ships in the one jobd package: the broker (jobd), the CLI (job), the MCP server (jobd-mcp), and the worker (jobd-worker). The worker's runtime deps (httpx, psutil, pyyaml, nvidia-ml-py) live behind the [worker] extra since they're only needed on machines that actually run jobs. scripts/install-worker.sh sets a worker up under ~/jobd-worker with its own venv and a generated config.
For a real multi-host deployment (Docker broker + systemd workers, Tailscale binding, shared auth token), see docs/security.md and the templates in docker-compose.yml and scripts/. Adding a worker to a running fleet is one command:
Day-2 operations (health, draining a worker, upgrades, token rotation, backups) are in docs/runbook.md.
Python 3.11+ everywhere.
| Component | Linux | macOS | Windows |
|---|---|---|---|
Broker (jobd) | ✅ | ☑️ | ☑️ (WSL recommended) |
CLI (job) / MCP (jobd-mcp) | ✅ | ☑️ | ☑️ |
Worker (jobd-worker) | ✅ full | ⚠️ degraded | ⚠️ degraded |
✅ = CI-tested (the test matrix runs on Linux). ☑️ = pure-Python and expected to work, but not exercised by CI — please file an issue if something is broken there.
The worker runs its best on Linux with a systemd user instance: memory caps, process reaping, and preemption use systemd-run --user scopes and cgroups. On non-systemd hosts the worker still executes jobs, but silently drops those guarantees — fine for a single trusted box, not for hard resource isolation. GPU features need NVIDIA + nvidia-ml-py. The broker, CLI, and MCP server are pure-Python and portable.
job submit --explain dry-runs the resolution (priority, profile, project defaults, host pin) and prints the effective config without enqueuing anything.
Submit N jobs from one template with --count N. Each member is a normal job — it routes, runs, preempts, and checkpoints independently — and {i} in the command is replaced by the member's 0-based index:
The array is identified as A<id> (the first member's job id). job status A42 exits non-zero if any member ended in a non-completed terminal state, so it composes with shell &&.
For a grid search, use --sweep KEY=v1,v2,v3 (repeatable) instead of --count. The broker fans out the cartesian product of all axes, substituting {KEY} per member; {i} (the flat member index) is also available:
--sweep and --count are mutually exclusive, the product is capped at 1000 members, and i is reserved as an axis key. Substitution is a literal {key} replace (not str.format), so JSON literals and shell braces in the command pass through untouched.
The verbs map directly — what changes is that the queue spans every machine you own:
| You ran… | With jobd |
|---|---|
tsp <cmd> / pueue add -- <cmd> | job submit -p <project> -- <cmd> |
tsp -w / pueue follow <id> | job logs -f <id> (or job wait <id>) — streams, exits with the job's own exit code |
tsp / pueue status | job list |
pueue log <id> | job logs <id> |
commands piped to simple_gpu_scheduler | ... | job submit -p <project> --stdin — one job per line, fleet-wide |
pueue kill <id> | job cancel <id> |
pueue group / parallelism limits | projects + priorities (projects.yaml); per-worker slots via JOBD_WORKER_MAX_CONCURRENT_JOBS |
What you gain on top: jobs route to whichever machine actually has the VRAM/CPU free, survive any single box rebooting, can be preempted with a checkpoint window instead of killed, and are drivable by an LLM agent over MCP. What you lose: nothing — a one-machine deployment (broker + one worker on the same host) behaves like a network-reachable pueue.
jobd ships an MCP server (jobd-mcp) exposing the queue as nine tools — jobd_submit, jobd_status, jobd_logs, jobd_list, jobd_cancel, jobd_preempt, jobd_events, jobd_workers, jobd_worker_delete. docs/agent-cookbook.md is the worked tour: fire-and-babysit polling, surviving preemption with checkpoints, sweeps, and asking the broker why a job won't schedule.
One-liner for Claude Code:
Or point any other MCP client at it:
JOBD_API_TOKEN must match the broker's token, or every call returns 401. Omit it only when the broker runs with JOBD_ALLOW_NO_AUTH=1.
Now an agent can "run this overnight," check on it next session, and route GPU work through the broker instead of colliding on a shared card. The examples/claude-code-hooks/ directory has optional Claude Code hooks that nudge (or hard-block) an agent toward submitting heavy commands through jobd — including a VRAM-aware GPU guard with # NO_GPU / # CONCURRENT_OK / # VRAM=NGB override markers.
Three optional YAML files under JOBD_CONFIG_DIR (defaults shipped in config/):
projects.yaml — per-project base priority and submit defaults (preemptibility, wall/idle timeouts, host pins, capability requirements). Entries may also declare roots: so a job typed with an unregistered run label is priced by the project whose directory it runs in. See docs/projects-yaml.md for the full resolution model and docs/events.md for the event catalog.profiles.yaml — named resource bundles (--profile gpu-train-large) the matcher uses to size a job.classifier.yaml — rules that auto-suggest a profile from the command string.All three are optional; with none present, every job runs at the global default priority.
Everything else is environment variables — the complete JOBD_* catalog (broker, worker, CLI/MCP, and the vars provided to workloads) lives in docs/configuration.md, and a CI test keeps it in lockstep with the source in both directions.
By default each worker runs one job at a time (JOBD_WORKER_MAX_CONCURRENT_JOBS=1). Raise it to let a worker bin-pack several jobs that fit side by side:
The matcher is resource-aware, so this is not blind N-up oversubscription. Each in-flight job reserves its vram_gb / ram_gb / cpus footprint, and the worker's heartbeat advertises only what's left (free_vram = raw − Σ in-flight). The broker won't place a job that doesn't fit the remaining headroom. The practical payoff: a CPU-only job and a GPU job run at the same time — the CPU job reserves 0 VRAM, so it never blocks the GPU slot, and vice-versa. Two GPU jobs co-run only if both fit live VRAM (the /next-job admission gate is the final safety net against an overstated ad).
job workers reports each worker's slot usage — running jobs out of max_concurrent — alongside the live resource ad:
Set the limit per worker from its environment (systemd unit, shell, or worker.yaml env) — it's a worker-local knob, not a broker setting.
By default jobd keeps every job record and .log file forever — history is never lost. On a long-running broker, opt into pruning:
The sweeper deletes jobs in a terminal state whose finished_at is older than the horizon, unlinks their per-job .log, and emits a jobs_pruned event. Freed SQLite pages are reused under WAL, so the DB file stays bounded without a global-locking VACUUM. The default (0) keeps everything; pruning old terminal parents is safe for any still-pending dependents.
The broker has no TCP-layer auth beyond a shared bearer token, so it is meant to run on a trusted network (loopback or a Tailscale tailnet), never on a public interface. Two stacked controls:
JOBD_HOST must be 127.0.0.1 or a Tailscale CGNAT address (100.64.0.0/10), never 0.0.0.0. A CI lint (tests/test_deploy_lint.py) enforces this on the Docker deployment.JOBD_API_TOKEN (≥32 random bytes) on every broker/worker/CLI/MCP host. The broker refuses to start without it unless you explicitly set JOBD_ALLOW_NO_AUTH=1. JOBD_ALLOW_NO_AUTH=1 is for a loopback-only broker (JOBD_HOST=127.0.0.1) — for local dev/tests. Combined with a non-loopback JOBD_HOST it exposes an unauthenticated RCE endpoint to your whole tailnet; the broker logs a startup warning if you do this. Don't.Three endpoints are exempt from both controls — /livez, /readyz and /metrics answer with no bearer token and no source-IP check, because a generic HTTP monitor cannot send a token. /metrics is the one that matters: it publishes the broker version, job counts by state, and every worker's hostname and version. No commands, cwd, env or project names — but it does fingerprint the fleet. That is why the JOBD_HOST bind above is load-bearing rather than defence-in-depth: port-forward the broker and you publish that inventory. Full table: Unauthenticated surface.
Full threat model, env-var reference, and token rotation: docs/security.md.
MIT — see LICENSE.