A small, self-hosted job orchestrator + dashboard for an always-on Mac Mini.
One long-lived daemon (kept alive by launchd) schedules and runs all jobs in isolated child processes, records every run to SQLite, and a Next.js dashboard shows live progress, history, durations, and pass/fail — plus push alerts on failure. Built to host long-running / headless local work that doesn't fit serverless or a web request.
launchd ──keeps alive──▶ daemon ──spawns──▶ job (isolated child process)
│ scheduler (croner) │ emits NDJSON
│ executor (timeout/retry) ▼ progress + logs
│ HTTP API on :4789 SQLite (jobs/runs/logs)
▼
dashboard (Next.js, :4788) ◀── polls the API, read-only
- The daemon is the engine. It schedules and runs jobs and records results even with no dashboard open. It must stay running for work to happen.
- The dashboard is a read-only window onto the daemon. Jobs run whether it's up or not.
| URL | |
|---|---|
| Daemon HTTP API | http://127.0.0.1:4789 |
| Dashboard | http://localhost:4788 |
The API binds to loopback only by default. CORS is an allowlist, never *.
Mutating endpoints accept loopback callers only (non-loopback callers need a
shared token). See .env.example for configuration.
Put the dashboard on your private Tailscale tailnet — reachable from your phone/laptop anywhere, but not the open internet. The API never leaves loopback; only the dashboard origin is shared, and it proxies the API server-side.
One-time setup on the Mini (after tailscale up):
tailscale serve --bg 4788
tailscale serve status # confirm
tailscale funnel status # must show no funnel configuredThen open https://<machine>.<tailnet>.ts.net/ from any device on the tailnet.
Never tailscale funnel this dashboard — Funnel is public internet.
cd ~/Development/local-jobs
# 1. the engine
bash scripts/install-launchd.sh
# 2. the always-on dashboard (build it first)
cd dashboard && npm run build && cd ..
bash scripts/install-dashboard-launchd.sh
# 3. stop the Mini sleeping so schedules actually fire (needs sudo)
sudo pmset -a sleep 0 disablesleep 1After this you never manually start anything — reboot and both come back.
Manage them:
launchctl list | grep localjobs # both should appear
tail -f data/daemon.out.log # daemon activity
launchctl kickstart -k gui/$(id -u)/com.ryankrol.localjobs # restart daemon
launchctl kickstart -k gui/$(id -u)/com.ryankrol.localjobs-dashboard # restart dashboard
# uninstall:
launchctl unload ~/Library/LaunchAgents/com.ryankrol.localjobs.plist
launchctl unload ~/Library/LaunchAgents/com.ryankrol.localjobs-dashboard.plistnpm install
npm run daemon # scheduler + API on :4789
cd dashboard
npm install
npm run dev # dashboard on http://localhost:4788Workflows own everything schedule-related. Every job belongs to a workflow; the workflow is the only thing that carries a cron schedule, an enable toggle, and a run button.
- Scheduled: a workflow's cron
schedulefires it automatically. - Manual: dashboard → Workflows → [workflow] → ▶ Run now.
- Edit schedule: the workflow detail page's Schedule row has an Edit affordance — takes effect on the live scheduler with no daemon restart.
- One active run per workflow at a time — a second concurrent start is rejected (409 from the API, "Running…" in the UI).
- Cancel: a running workflow can be cancelled via the ✕ Cancel button on
the run detail page (hard-kills the in-flight child, marks the run
cancelled). - Pause a workflow via its enable toggle without deleting it.
Every job must be declared in a *.workflow.ts manifest — even a lone job
(one-stage workflow). A job with no manifest is a config error and the daemon
refuses to start.
- Create
src/jobs/<name>.job.tsexporting aJobDefinition:import type { JobDefinition } from '../core/types.js'; const job: JobDefinition = { name: 'cleanup-temp', description: 'Deletes stale temp files', timeoutMs: 600_000, maxRetries: 3, async run(ctx) { ctx.log('starting'); ctx.progress(50, 'halfway'); // throw to fail the run }, }; export default job;
- Declare it in a
*.workflow.tsmanifest:import type { WorkflowDefinition } from '../core/types.js'; const workflow: WorkflowDefinition = { name: 'cleanup-temp', description: 'Nightly temp-file cleanup', schedule: '0 4 * * *', // croner syntax; null = manual-only jobs: [{ job: 'cleanup-temp' }], }; export default workflow;
- Restart the daemon — jobs and workflows are auto-discovered (no registry
to edit):
launchctl kickstart -k gui/$(id -u)/com.ryankrol.localjobs
Your jobs stay private by default. This repo is public; every
src/jobs/*.job.ts(and any private subfolder you add) is gitignored, so the jobs you add stay local-only unless you publish them. Every job'sdata/folder is always gitignored. Secrets go in.env(gitignored), never in code.
Gotcha: the daemon loads job code at startup, so any change to job/daemon code needs a daemon restart to take effect.
For full architecture, conventions, and how multi-stage workflows are structured
see CLAUDE.md.
Ten worked examples are published under src/jobs/ (their data/ stays
gitignored). Private workflows live in gitignored subfolders.
- places — Google Saved Places enrichment: parse CSVs → resolve CIDs → Google Places API → Gemini LLM summaries → markdown profiles. Daily at 03:00.
- perfumes — Fragrantica profile builder: find URL → headless-Chrome fetch →
parse notes/accords → Claude CLI profile write. Uses
repeatUntilStablecycling. - missing-tv-seasons — Plex TV new-seasons audit: snapshot the TV library → check TMDB for complete missing seasons → weekly digest push (each missing season announced exactly once).
- movie-recommendations — Monthly Plex movie digest: snapshot the movie library → detect franchise gaps (TMDB Collections) AND fan out 8 Claude recommender branches → merge/verify/dedupe → one monthly digest of gaps + taste-based recommendations.
- tv-recommendations — Monthly Plex TV show recommendations: snapshot the TV library → 8 Claude recommender branches → merge/verify/dedupe → one monthly digest of new picks.
- workouts-sync — Monthly Hevy workout ingestion: paginate the Hevy API →
append each newly-synced workout's full data (title, exercises, sets) to a
local full-history JSON file (
data/out/workouts-history.json, no DynamoDB); idempotent per workout id (new workouts appended, already-synced ids skipped, so the history file only ever grows). Then a second stage computes a per-exercise 6-month progress report — best single set, total volume, and estimated 1-rep-max (Epley formula), comparing the most recently completed calendar month against the same month 6 months prior — and uses Claude to narrate it intodata/out/workouts-progress.md(raw comparison also written todata/out/progress-data.json). Runs monthly on the 1st at 06:00. - listening-digest — Monthly Last.fm listening digest: fetch top albums + top
tracks (
period=1month) directly from Last.fm's own aggregation endpoints (no raw scrobble ingestion, no DynamoDB), filter out single-track-dominated "albums", and write a markdown report todata/out/. Idempotent per calendar month via the work_items ledger; a manual re-run the same month regenerates that month's file. Runs monthly on the 1st. - projects-sync — Weekly GitHub repo ingestion, 2-stage DAG. Stage 1 (
github-sync) fetches the owner's repos via the GitHub REST API → filters out forks/archived/private → sorts by pushed_at → writes the filtered list to a localdata/out/projects.jsoncatalog; idempotent per GitHub numeric repo id (repoId) via the work_items ledger, refreshing fields every run. Stage 2 (project-summarize) shallow-clones each cataloged repo, reads its README, and asks Claude (via the sharedclaude-cliservice) to write a one-project markdown summary todata/out/<repo-name>.mdthat MUST follow the enforcedproject.template.mdoutput contract (YAML frontmatter incl.themes/domainplus fixed##sections likeThemes & InterestsandNotable Technical Approaches, designed as a queryable cross-project "second brain" corpus, override viaPROJECTS_SYNC_TEMPLATE_PATH) — a response missing the frontmatter marker or any required section is rejected and the item marked failed. Idempotent per repo by comparing the catalog'spushedAtagainst the last-processed marker stored on the work_items ledger — a repo unchanged since its last summary is skipped entirely (no clone, no Claude call). Runs weekly, Sunday at 05:00. - claude-warmer — Proactive Claude usage-window warmer: issue one minimal
"hi"prompt via theclaude-cliservice every 30 minutes so the Claude account's 5-hour rolling usage window is already running (or reset) by the time real work needs Claude. Soft-fails gracefully if the upstream plan limit is reached; no local quota cap needed. Runs every 30 minutes (*/30 * * * *). - stocks-sync — Daily Trading212 portfolio snapshot + gain-alert, strictly read-only
(GET-only, no order placement/cancellation/account mutation — see the "Broker / trading APIs
are READ-ONLY" rule). 3-stage DAG. Stage 1 (
stocks-snapshot) calls Trading212's open-positions endpoint (https://docs.trading212.com/api) and writes a broker-agnostic snapshot to a localdata/out/portfolio.json(structured) +data/out/portfolio.md(one row per position with the price difference since purchase, as both an absolute amount and a percentage) — no DynamoDB. Also fetches an OPTIONAL second Stocks & Shares ISA account (Trading212 API keys are scoped one key/secret pair per account) whenTRADING212_ISA_API_KEY_ID+TRADING212_ISA_API_SECRET_KEYare both set; each position is tagged with which account it came from (invest/isa), shown as an Account column inportfolio.md, and keyed by a compositeaccount:tickerledger key so the same ticker held in both accounts never collides. Idempotent peraccount:tickervia the work_items ledger. Stage 2 (stocks-watch, depends onstocks-snapshot) checks EVERY position's gain since average buy price EVERY run and records it in the ledger unconditionally, then writes this run's fresh 30%+ breaches todata/out/fresh-breaches.json— the check always reports success when it ran (it can never legitimately show as skipped/noop). Stage 3 (stocks-notify, depends onstocks-watch) readsfresh-breaches.jsonand sends one push naming every freshly breaching position, or does nothing if the file is empty (a real, expected noop, unlike stocks-watch). Notified once per breach episode (staying above 30% doesn't re-notify every run); if a position later drops back below 30% its notified-flag resets, so a future re-breach notifies again. Runs daily (schedule editable from the dashboard). - stock-digest — Weekly Claude-narrated markdown summary of current stock
holdings, performance movers, and a sector/diversification breakdown, DISTINCT
from
stocks-sync(own folder, own workflow, own weekly schedule —'0 8 * * 1', Monday 08:00, deliberately afterstocks-sync's daily 07:00 run so a same-day-fresh snapshot is usually available). Two-stage DAG:stock-sector-lookup→stock-digest-build. Both stages readstocks-sync'sdata/out/portfolio.jsondirectly via a plain relative import of its config/types — the two workflows are NOT DAG-linked (the framework has no cross-workflowdependsOn), so this is a same-repo cross-workflow file read, not a wired dependency; a missing or empty portfolio file logs a WARN and cleanly skips the run instead of crashing.stock-sector-lookupresolves each currently-held ticker's industry via the Finnhub company-profile API (FINNHUB_API_KEY), writingdata/out/sectors.json; idempotent per ticker via the work_items ledger (already-resolved tickers are skipped on later runs). A missing/unset key soft-skips the lookup, andstock-digest-buildsimply omits the diversification section.stock-digest-buildcomputes each position's gain since average buy price and its share of total portfolio value, ranks the biggest winners/losers, groups portfolio value by resolved industry, and asks Claude to narrate a holdings + performance + diversification report todata/out/stock-digest-<ISO-week>.md. Idempotent per ISO week via the work_items ledger. Markdown-only output — no push notification is sent. Runs weekly, Monday at 08:00.
Nav: Overview · Workflows · Services · Database · Backlog
- Overview — clickable stat tiles (Running / Succeeded / Failed / Cancelled / Stuck / Ignored); stuck-items list with per-item Unstick / Ignore controls and bulk actions.
- Workflows — every workflow with schedule, enabled state, member-job count, and last/next run. Drill in to reach member jobs.
- Workflow detail — ▶ Run now, enable toggle, editable schedule, editable max concurrency, a click-to-toggle Notifications switch (on/off for the run-end push notification; default on), full run history, and a Danger zone → Clear output data action.
- Workflow run detail — live logs, per-stage outcomes, overall progress bar, cancel button (while running), and an Input → Output mapping panel scoped to this run.
- Job detail — read-only member view: timeout/retries, run history, stuck items. You run + enable its workflow, not the job directly.
- Run detail — live progress bar + streaming logs, duration, exit code, error.
- Services — usage counts vs caps, current call rate, editable rate/quota limits, and a consumer list (which workflows/jobs have called each service).
- Database — read-only SQLite view: named common queries + a table browser. Not a free-form SQL editor.
- Backlog — human-readable render of the harness task list (
.harness/tracking/TASKS.json), with per-task Do/Done-when rendered from spec files, and a reviewed toggle that commits + pushes durably to GitHub.
See .env.example:
| Var | Purpose |
|---|---|
LOCALJOBS_PORT |
API port (default 4789) |
LOCALJOBS_HOST |
API bind address (default 127.0.0.1) |
LOCALJOBS_ALLOWED_ORIGINS |
Comma-separated CORS allowlist |
LOCALJOBS_TOKEN |
Shared secret for non-loopback mutating endpoints |
LOCALJOBS_DB |
SQLite path (default ./data/jobs.db) |
LOCALJOBS_NTFY_TOPIC |
ntfy.sh push-alert topic; blank = off |
LOCALJOBS_NTFY_SERVER |
ntfy server (default https://ntfy.sh) |