moadim
Loop engineering, on a schedule. Stop prompting your agents — design the loop that prompts them.
Cron jobs that run while you sleep. One port. Three interfaces. Zero drift.
Set the loop. Forget the keyboard. moadim fires the prompt so you don't have to.
One-line install — install Rust/Cargo, install moadim, then run it:
| && && &&
Rust server that exposes cron job management over three interfaces simultaneously:
- UI (
http://localhost:5784/) — browser dashboard for managing jobs - REST (
http://localhost:5784/api/v1) — standard HTTP API for browsers, CLI tools, and services - MCP (
http://localhost:5784/mcp) — Model Context Protocol for AI agents (Claude, etc.)
All three share the same port. Jobs created through any interface are automatically synced to the OS crontab so they actually run on schedule.
Installation
If moadim is not found after install, add Cargo's bin directory to your PATH:
&&
Then run:
This starts the server in the background and returns control to your shell.
Stop it later with moadim stop (or the STOP button in the UI). To run it
attached to your terminal instead, use moadim --interactive.
Features
Close the loop. Skip the keyboard. Loop engineering, shipped as a daemon.
- Jobs created via REST or MCP are written into your OS crontab automatically
- Edit the crontab directly and moadim picks up the changes within 30 s
- Job declarations live in
~/.config/moadim/jobs/— git-trackable, diff-friendly - Handlers are executable scripts in
~/.config/moadim/handlers/— any language, also git-trackable job.local.tomlper job for secrets and machine-specific overrides that stay off-git- Same REST and MCP interface — no logic duplication between protocols
- API spec auto-generated at build time into
apis/
Directory layout
~/.config/moadim/
├── jobs/
│ ├── daily-report/
│ │ ├── job.toml # tracked — commit this
│ │ ├── job.local.toml # untracked — local overrides (secrets, machine-specific config)
│ │ └── job.local.log # untracked — runtime log
│ ├── cleanup-temp/
│ │ ├── job.toml
│ │ └── job.local.log
│ └── sync-calendar/
│ ├── job.toml
│ └── job.local.toml
└── handlers/
├── send-report.sh
├── cleanup-temp.py
└── sync-calendar.sh
Crontab sync
Two-way sync, zero surprises. Your crontab, your rules — we just keep them honest.
Moadim owns a single block inside your crontab. Everything outside that block is untouched.
# BEGIN MOADIM
# Managed by moadim — manual edits to this block sync back automatically
30 9 * * 1-5 /home/user/.config/moadim/handlers/send-report # moadim:uuid
0 0 * * 0 /home/user/.config/moadim/handlers/cleanup-temp # moadim:uuid
# END MOADIM
Forward sync (moadim → crontab): any time you create, update, or delete a job via the UI, REST, or MCP, the crontab block is rewritten immediately. Disabled jobs are excluded from the block.
Reverse sync (crontab → moadim): on startup and every 30 seconds, moadim reads the block and applies any changes back into its store and TOML files. This means you can edit the crontab directly — change a schedule, swap a handler — and moadim will pick it up without a restart.
Schedule format: standard 5-field cron (min hour dom month dow), same as the OS crontab. @keyword shortcuts (@daily, @hourly, @weekly, @monthly, @reboot) are also accepted.
Timezone: because jobs run via the OS crontab, schedules are evaluated in the host's local system timezone, not UTC. A schedule of 0 9 * * * fires at 09:00 local time. AI agents in particular should not pre-convert times to UTC.
Handlers
Handlers are executable scripts under ~/.config/moadim/handlers/. The handler field in job.toml is the filename without extension.
handlers/send-report.sh ← handler = "send-report"
handlers/cleanup-temp.py ← handler = "cleanup-temp"
Any executable works — shell, Python, Node, compiled binary. The server passes job metadata as environment variables prefixed with MOADIM_.
#!/usr/bin/env bash
# ~/.config/moadim/handlers/send-report.sh
Multiple jobs can share one handler, differing only in schedule or metadata:
jobs/daily-report/job.toml → handler = "send-report"
jobs/weekly-digest/job.toml → handler = "send-report"
Handlers are git-trackable alongside jobs:
Job declarations
Each job is a folder under ~/.config/moadim/jobs/. The folder name is the job ID.
Each job folder contains an auto-generated .gitignore that excludes *.local.* and *.log files — no manual setup needed.
job.toml
Tracked configuration — schedule, handler, and shared metadata.
# ~/.config/moadim/jobs/daily-report/job.toml
= "30 9 * * 1-5" # cron expression (min hour dom month dow)
= "send-report" # filename in ~/.config/moadim/handlers/ (no extension)
= true # omit to default to true
[]
= "team@example.com"
= "Asia/Jerusalem"
| Field | Type | Required | Description |
|---|---|---|---|
schedule |
string | yes | Cron expression: min hour dom month dow or @daily, @hourly, etc. |
handler |
string | yes | Script name in handlers/ (without extension) |
enabled |
bool | no | Defaults to true. Set false to pause without deleting. |
[metadata] |
table | no | Key/value pairs passed to the handler as MOADIM_* env vars. |
job.local.toml
Untracked overrides — machine-specific values or secrets that should not be committed. Loaded after job.toml; local values win on any conflict.
# ~/.config/moadim/jobs/daily-report/job.local.toml
= false # overrides job.toml enabled = true → job is paused locally
[]
= "sk-..." # secret — never commit
= "me@local" # overrides job.toml recipient
job.local.log
Append-only log written by the server on each run. Gitignored via *.local.*. Readable in the UI via the LOGS button or GET /api/v1/cron-jobs/{id}/logs.
2026-06-11T09:30:00Z [daily-report] run started
2026-06-11T09:30:01Z [daily-report] run finished OK (1.2s)
Running
Moadim runs as a local daemon. By default it starts in the background:
| Command | Mode | Behaviour |
|---|---|---|
moadim |
background | Spawns a detached server, writes its PID to ~/.config/moadim/moadim.pid, logs to ~/.config/moadim/daemon.log, and exits. Refuses to start if one is already running. |
moadim -i |
interactive | Runs in the foreground; logs to the terminal; Ctrl-C stops it. |
moadim restart |
background | Stops the running server (if any) and spawns a fresh detached instance, so you get a clean process without a separate stop/start. Prints the PID rotation as restarted: pid <old> -> <new> (old reads none when nothing was running) so scripts/logs can confirm the process actually changed. |
moadim stop |
— | Sends POST /shutdown to the running server for a graceful stop. Add --json for {"running":bool,"pid":N|null} (the pid is read before the shutdown request, since a graceful stop clears the pid file). Exits 0 when a running server was asked to shut down, 3 when none was reachable. |
moadim status |
— | Prints whether a server is reachable on 127.0.0.1:5784. Add --json for {"running":bool,"pid":N|null,"address":"127.0.0.1:5784"}. Exits 0 when running, 3 when not. |
moadim cleanup |
— | Sends POST /api/v1/routines/cleanup to the running server and prints how many finished, expired routine workbenches were reaped (the on-demand version of the hourly sweep). Add --json for {"running":bool,"removed":N}. Exits 0 when running, 3 when not. |
status, cleanup, and stop follow a script-friendly exit-code contract so callers can branch
on $? without parsing stdout: they exit 0 when a server is running (and cleanup swept, stop
asked it to shut down) and 3 when no server is reachable. Any other failure exits non-zero (1)
with a message on stderr.
Scripting
status, cleanup, and stop each accept --json for a single-line, machine-readable object
on stdout. Paired with the exit codes above, a caller gets the full contract without parsing prose:
| Command | --json shape |
Exit codes |
|---|---|---|
moadim status --json |
{"running":bool,"pid":N|null,"address":"127.0.0.1:5784"} — pid is null when no pid file is present |
0 running, 3 not |
moadim cleanup --json |
{"running":bool,"removed":N} — removed is 0 when no server is running |
0 running, 3 not |
moadim stop --json |
{"running":bool,"pid":N|null} — running is true when a running server was asked to shut down; pid is the stopped server's PID (read before shutdown) or null when none was reachable |
0 running, 3 not |
Any other failure exits 1 with a message on stderr. The object is always a single line, so
moadim status --json | jq -r .pid and similar pipelines work without buffering.
Because the default mode is detached, you stop the server from the client:
press the STOP button in the UI header, run moadim stop, or send
POST /shutdown. (During development, cargo run -- --interactive keeps it in
the foreground.)
Starts on http://127.0.0.1:5784. On startup the server:
- Loads all jobs from
~/.config/moadim/jobs/. - Reads your crontab and applies any changes made to the moadim block while the server was stopped.
- Writes all enabled managed jobs back into the crontab block.
MCP usage
This is where the loop closes: your agent reads, schedules, and re-fires its own jobs. Loop engineering with a daemon in the middle.
The server exposes an MCP endpoint at http://localhost:5784/mcp. Connect any MCP-compatible client.
Claude Code
Add moadim at user scope so it's available across all your projects. moadim is a global daemon (one local server, one crontab) — there's no per-project state, so project scope would only force you to re-add it in every repo.
Any MCP client
transport: streamable-http
url: http://localhost:5784/mcp
API
Full interface definitions are auto-generated at build time — see the apis/ folder.
Changelog
Release history lives in CHANGELOG.md, following the
Keep a Changelog format.