Deterministic task runner for AI agents & systemd timers
Replace shell scripts and token-burning LLM cron jobs with a structured, self-documenting CLI. Every task produces JSON output, supports dry-run, and tracks state in SQLite — so agents and schedulers can inspect results without re-running.
⚡ Quick demo:
pip install docket-cli
docket configure # create ~/.docket/config.yaml
docket --json capabilities # ← agents start here
docket run backup --dry-run # safe preview, no side effects
docket run backup --async # fire & forget, get a run_id back| Problem | Docket's answer |
|---|---|
| Shell scripts don't speak JSON | --json on every command, structured output everywhere |
| Cron jobs fail silently | SQLite state tracking, completion hooks, native notifications |
| Agents burn tokens on simple ops | Deterministic tasks — no LLM needed, --json contract for agents |
| Long-running tasks block | --async returns a run_id, docket wait / docket logs to follow up |
| No way to preview before running | --dry-run on every task, zero side effects |
pip install docket-cliDev setup:
git clone https://github.com/bennybuoy/docket.git
cd docket && pip install -e ".[dev]"1. Create a config file:
docket configureOr manually create ~/.docket/config.yaml:
tasks:
backup:
source: /home/user/data
remote: remote:backup
tool: rclone
schedule: "0 2 * * *"
retry: 3
backoff_base: 2.0
on_success: "notify-send 'Backup succeeded'"
on_failure: "notify-send 'Backup failed'"
health-check:
checks: [disk, memory, systemd-units]
disk_threshold_pct: 85
memory_threshold_pct: 90
systemd_units: [nginx.service, postgresql.service]2. Run a task:
docket --json capabilities # discover commands and task contracts
docket run hbackup --dry-run # preview Hermes/OpenClaw backup
docket run hbackup --async # start hbackup in background
docket run backup --dry-run # preview
docket run backup # execute for real
docket run backup --async # start in background and return a run_id3. Check status:
docket status
docket status backup # specific task
docket runs backup # recent run history
docket wait <run_id> # block until an async run finishes
docket logs <run_id> # show captured stdout/stderr
docket logs <run_id> --follow # stream captured output until completion
docket events <run_id> # show structured JSONL events
docket events <run_id> --follow # stream structured events until completion
docket cancel <run_id> # terminate a running async task| Command | Description |
|---|---|
docket capabilities |
Show agent-facing command and task metadata |
docket list |
List all discovered tasks and their last run status |
docket new-task <task> |
Scaffold a drop-in task in tasks_dir |
docket run <task> |
Execute a task (--dry-run to preview, --force to bypass guards) |
docket run <task> --async |
Start a task in the background and return a durable run ID |
docket status [<task>] |
Show last run status for all tasks or a specific task |
docket status --run-id <id> |
Show a specific historical run |
docket runs [<task>] |
List recent historical runs |
docket wait <run_id> |
Wait for a running task to finish |
docket logs <run_id> |
Show captured stdout/stderr for an async run |
docket logs <run_id> --follow |
Follow captured async output until the run finishes |
docket events <run_id> |
Show structured JSONL events recorded for a run |
docket events <run_id> --follow |
Follow structured events until the run finishes |
docket cancel <run_id> |
Cancel a running async task |
docket describe <task> |
Show a task's description and resolved configuration |
docket install [<task>] |
Generate systemd timer + service units for scheduled tasks (--list to preview) |
docket configure |
Open config in $EDITOR with YAML validation on save |
docket doctor |
Self-diagnostic: checks config, state DB, and task discovery |
All commands support --json for structured output and -v / -vv for logging.
Agents should begin with the self-describing contract:
docket --json capabilitiesThe response includes the Docket version, agent_contract_version, configured
state/log/task directories, available commands, and every discovered task with
its description, resolved config, inputs, outputs, schema, required
environment variables, and safety flags:
{
"tool": "docket",
"agent_contract_version": 1,
"commands": [{"name": "run <task> --async", "json": true}],
"tasks": [
{
"name": "hbackup",
"description": "Back up Hermes/OpenClaw state with hbackup",
"inputs": {"action": "hbackup action: backup, auto, list, upload, or setup-drive"},
"outputs": {"archive_path": "Archive path reported by hbackup"},
"schema": {},
"required_env": [],
"network": true,
"destructive": false
}
]
}Use docket --json describe <task> for a focused view before running an
unfamiliar task.
docket run <task> --async --json returns immediately with a durable run_id,
the child process PID, and the exact status/wait commands an agent can call next.
{
"ok": true,
"status": "running",
"task": "backup",
"run_id": "20260427T013000000000Z-ab12cd34",
"pid": 12345,
"log_path": "/home/user/.docket/logs/20260427T013000000000Z-ab12cd34.log",
"next_check_after_seconds": 5,
"status_command": "docket --json status --run-id 20260427T013000000000Z-ab12cd34",
"wait_command": "docket --json wait 20260427T013000000000Z-ab12cd34",
"logs_command": "docket logs 20260427T013000000000Z-ab12cd34"
}Completed runs are stored in SQLite history, not just the latest task state, so
agents can inspect past outcomes with docket runs --json.
Async child output is captured under logs_dir, and agents can read it with
docket logs <run_id> without needing to know the filesystem layout. Use
docket logs <run_id> --follow for live log tailing, docket events <run_id> --json for parsed JSONL events, and docket events <run_id> --follow to watch
structured command-task events as they arrive.
Configuration lives in ~/.docket/config.yaml. The full schema:
# Top-level settings
state_dir: ~/.docket/state.db # SQLite database for run history
tasks_dir: ~/.docket/tasks # Drop-in directory for custom task .py files
logs_dir: ~/.docket/logs # Captured logs for async task runs
# Per-task configuration under "tasks"
tasks:
backup:
source: /data/backup # Local path to back up
remote: remote:backup # rclone remote destination
tool: rclone # "rclone", "rsync", or "hbackup"
extra_args: [] # Additional CLI args for the backup tool
idle_timeout_seconds: 1800 # Kill if no output/progress for this many seconds
schedule: "0 2 * * *" # Cron schedule (for docket install)
retry: 3 # Max retry attempts on failure
backoff_base: 2.0 # Exponential backoff base (seconds)
on_success: "notify-send 'Backup done'" # Shell command(s) on success
on_failure: "notify-send 'Backup failed'" # Shell command(s) on failure
on_complete: "curl -X POST https://example.test/docket" # Always runs
notify:
on: [success, failure] # Default is [failure] when omitted
discord:
webhook_url_env: DISCORD_WEBHOOK_URL
telegram:
chat: "8223639759"
bot_token_env: TELEGRAM_BOT_TOKEN
docket_bin: docket # Override docket binary path for systemd units
health-check:
checks: [disk, memory, systemd-units] # Which checks to run
disk_threshold_pct: 90 # Fail if disk usage exceeds this %
memory_threshold_pct: 90 # Fail if memory usage exceeds this %
systemd_units: [] # Units that must be active
schedule: "*/15 * * * *" # Every 15 minutes| Key | Type | Default | Description |
|---|---|---|---|
schedule |
string | — | Cron expression for systemd timer generation |
retry |
int | 0 |
Max retry attempts when task fails |
backoff_base |
float | 2.0 |
Base for exponential backoff (wait = backoff_base ** attempt) |
on_success |
string or list | — | Shell command(s) to run on task success |
on_failure |
string or list | — | Shell command(s) to run on task failure |
on_complete |
string or list | — | Shell command(s) to run after any terminal result |
notify |
mapping | — | Native Discord/Telegram notifications for task completion |
docket_bin |
string | docket |
Path to docket binary for generated systemd units |
All other keys are passed directly to the task's run() / dry_run() as the config dict.
Docket validates shared config before commands run. Top-level state_dir,
tasks_dir, and logs_dir must be strings; tasks must be a mapping; task
configs must be mappings.
Shared per-task keys are typed:
schedule,description,docket_bin: non-empty stringsretry: non-negative integerbackoff_base: positive numberon_success,on_failure,on_complete: string or list of stringsnotify: mapping withon,discord, and/ortelegraminputs,outputs: mapping of string keys to string descriptionsrequired_env: list of stringsnetwork,destructive: booleans
Built-in tasks get extra validation:
backup.tool:rclone,rsync, orhbackupbackup.source,backup.remote: non-empty strings when presentbackup.extra_args: list of stringsbackup.idle_timeout_seconds: non-negative number ornullbackup.hbackup_command:backuporautobackup.hbackup_bin,backup.output: non-empty stringsbackup.excludes: list of stringshbackup.action:backup,auto,list,upload, orsetup-drivehbackup.hbackup_bin,hbackup.output,hbackup.archive,hbackup.destination,hbackup.drive_remote,hbackup.drive_folder: non-empty stringshbackup.excludes,hbackup.extra_args: list of stringshbackup.drive: booleanhbackup.idle_timeout_seconds: non-negative number ornullhealth-check.checks: list containingdisk,memory, and/orsystemd-unitshealth-check.*_threshold_pct: number from0to100health-check.systemd_units: list of strings
Unknown keys on custom tasks are allowed so task plugins can define their own configuration contract.
Command tasks (type: command) get extra validation:
command: string or list of stringsargs,dry_run_args: list of stringsdry_run_command: string or list of stringsoutput_format:text,json,jsonl, orautosuccess_exit_codes: list of integersenv: mapping with string keyscwd: non-empty stringtimeout_seconds: positive number
Create a drop-in task scaffold:
docket --json new-task fetch-invoices --description "Fetch invoices from the API"Then edit the generated file in tasks_dir, or create a Python class extending
BaseTask yourself:
from docket.task import BaseTask, TaskResult
class MyTask(BaseTask):
name = "my-task"
description = "Does something useful"
inputs = {"api_url": "API endpoint to fetch"}
outputs = {"records_fetched": "Number of records fetched"}
schema = {"api_url": {"type": "string", "required": True}}
required_env = ["API_TOKEN"]
network = True
destructive = False
def run(self, config: dict) -> TaskResult:
# ... actual work ...
return TaskResult(ok=True, summary="Done", details={"records_fetched": 12})
def dry_run(self, config: dict) -> TaskResult:
return TaskResult(ok=True, summary="Would do something", dry_run=True)Register via entry point in pyproject.toml:
[project.entry-points."docket.tasks"]
my-task = "my_package.tasks:MyTask"Or drop a .py file into ~/.docket/tasks/.
The metadata fields are optional but strongly recommended for agent use:
| Field | Meaning |
|---|---|
inputs |
Config keys or external inputs the task expects |
outputs |
Machine-readable values the task returns in TaskResult.details |
schema |
Optional config validation schema checked before execution |
required_env |
Environment variables needed at runtime |
network |
Whether the task calls external services |
destructive |
Whether the task can delete, overwrite, charge money, or mutate important state |
See docs/writing-tasks.md for the full guide.
Use type: command when the implementation should live in Rust, Go, Bash,
Node, or any existing executable:
tasks:
fetch-invoices:
type: command
description: Fetch invoices with the Rust importer
command: invoice-fetcher
args: ["--account", "main", "--jsonl"]
dry_run_args: ["--account", "main", "--dry-run", "--json"]
output_format: jsonl
required_env: [INVOICE_API_TOKEN]
inputs:
account: Account slug to fetch
outputs:
events: JSONL progress/result events emitted by the command
network: true
destructive: falseFor output_format: json, Docket parses stdout into details.json.
For output_format: jsonl, each stdout line must be a JSON object; Docket
stores parsed events in details.events, details.event_count, and
details.last_event. Raw stdout/stderr are still preserved in the result and,
for async runs, the Docket child output is captured under logs_dir.
JSONL events are also promoted into SQLite and can be queried independently:
docket --json events <run_id>
docket events <run_id> --followEach event record includes run_id, task_name, event_index, timestamp,
event_type, and the parsed event object. Async command tasks stream JSONL
events into this table while the command is still running.
If dry_run_args or dry_run_command is omitted, docket run <task> --dry-run
returns a safe preview without executing the command.
on_complete runs after every task result is recorded, regardless of success or
failure. on_success and on_failure still run only for matching outcomes.
Hooks receive structured environment variables:
| Variable | Meaning |
|---|---|
DOCKET_RUN_ID |
Durable run ID |
DOCKET_TASK_NAME |
Task name |
DOCKET_STATUS |
succeeded, failed, cancelled, etc. |
DOCKET_EXIT_CODE |
Final Docket exit code |
DOCKET_SUMMARY |
One-line result summary |
DOCKET_DETAILS_JSON |
JSON-encoded result details |
DOCKET_LOG_PATH |
Async log path, when available |
This is the lightweight way to ping an agent, webhook, or monitor when a long job finishes.
For common agent channels, use notify instead of shelling out to curl.
Docket currently supports Discord webhooks and Telegram bot messages:
tasks:
vitals-morning:
notify:
on: [success, failure] # success, failure, or complete
message: "{task} {status}: {summary} ({run_id})"
discord:
webhook_url_env: DISCORD_WEBHOOK_URL
username: Docket
telegram:
chat: "8223639759"
bot_token_env: TELEGRAM_BOT_TOKENWhen notify.on is omitted, notifications default to failures only. Prefer
*_env settings so secrets stay in the environment instead of YAML. Delivery
results are included in --json output under hooks as notify:discord and
notify:telegram entries.
First-class integration for hbackup, the Hermes/OpenClaw backup and restore CLI.
tasks:
hbackup:
action: auto # backup, auto, list, upload, setup-drive
hbackup_bin: hbackup
idle_timeout_seconds: 1800
schedule: "0 3 * * *"
retry: 1
on_complete: "curl -X POST https://example.test/docket-complete"Agent workflow:
docket --json describe hbackup
docket --json run hbackup --dry-run
docket --json run hbackup --async --force
docket logs <run_id> --follow
docket --json status --run-id <run_id>action: auto lets hbackup create and upload using its own config while Docket
provides the agent contract: run IDs, async receipts, logs, retries, history,
cancellation, completion hooks, and task discovery.
Syncs a local directory to a remote destination using rclone (default) or
rsync. It can also wrap hbackup through tool: hbackup, or create a
.tar.zst archive and upload it to Google Drive with gog through
tool: gog.
Config keys: source, remote, tool (rclone, rsync, hbackup, or
gog), extra_args, idle_timeout_seconds, hbackup_bin,
hbackup_command, output, excludes, gog_bin, account, parent,
archive_name, and mime_type.
tasks:
hbackup:
action: auto
schedule: "0 3 * * *"
retry: 1
on_complete: "notify-send 'hbackup finished'"
backup:
source: /home/user/data
remote: remote:backup
tool: rclone
idle_timeout_seconds: 1800idle_timeout_seconds is activity-based, not a hard job duration limit. Large
backups can run for hours as long as rclone or rsync continues to emit output
or progress. Set it to 0 or null to disable idle timeout handling.
Compatibility hbackup backend:
tasks:
hbackup:
action: auto # backup, auto, list, upload, setup-drive
hbackup_bin: hbackup # hbackup binary path or command name
idle_timeout_seconds: 1800
schedule: "0 3 * * *"
retry: 1
on_complete: "curl -X POST https://example.test/docket"
backup:
tool: hbackup
hbackup_command: backup # backup or auto
output: ~/backups/openclaw-hermes.tar.zst
excludes: [target, .git]docket run backup --dry-run maps to hbackup backup --dry-run.
For first-class hbackup orchestration, use docket run hbackup.
Google Drive upload with gog backend:
tasks:
backup:
tool: gog
source: /home/openclaw/.openclaw
account: benkamholtz@gmail.com
parent: 1okGC-kRTFSzIGhkmfnxO8_zO0if9El1q # Drive folder ID
archive_name: openclaw-backup.tar.zst
excludes:
- .openclaw/workspace-*
- .openclaw/github-backup
- .openclaw/cache
idle_timeout_seconds: 1800
required_env: [GOG_KEYRING_PASSWORD]tool: gog creates a temporary tar --zstd archive from source, then runs
gog drive upload --json --no-input --name <archive_name> --parent <parent>.
excludes are passed to tar --exclude, so patterns should be written relative
to the parent of source (for example .openclaw/workspace-* when
source: /home/openclaw/.openclaw).
It preserves Docket's run history, async receipts, hooks, dry-run preview, and
activity-based idle-timeout behaviour.
Runs system health checks: disk usage, memory usage, and systemd unit status.
Config keys: checks (list of disk, memory, systemd-units), disk_threshold_pct, memory_threshold_pct, systemd_units.
tasks:
health-check:
checks: [disk, memory, systemd-units]
disk_threshold_pct: 85
memory_threshold_pct: 90
systemd_units: [nginx.service]| Code | Meaning |
|---|---|
0 |
Success |
1 |
Failure (task error, lock contention, config error) |
2 |
Dry-run completed (no side effects) |
CLI (click)
│
├─► Registry ── discovers ──► BaseTask subclasses
│ (entry points + ~/.docket/tasks/)
│
├─► Config (YAML loader with defaults)
│
├─► Task.run() / Task.dry_run()
│ ├─► Retry (exponential backoff)
│ ├─► LockFile (prevent concurrent runs)
│ └─► Hooks (on_success / on_failure / on_complete shell commands)
│
└─► StateStore (SQLite)
└─ latest task state + immutable run history
Key modules:
| Module | Role |
|---|---|
docket.cli |
Click CLI — all commands |
docket.task |
BaseTask ABC + TaskResult dataclass |
docket.registry |
Task discovery via entry points and drop-in directory |
docket.config |
YAML config loader with path expansion and defaults |
docket.state |
SQLite-backed state store (latest state and run history) |
docket.lockfile |
Cross-platform lock files to prevent concurrent runs |
docket.retry |
Exponential backoff retry logic |
docket.hooks |
Notification hooks (on_success / on_failure / on_complete) |
docket.systemd |
systemd unit generation and cron↔OnCalendar conversion |
docket.logging |
Dual JSON / human-readable log setup |
# Install with dev dependencies
pip install -e ".[dev]"
# Run tests
python -m pytest tests/ -q
# Lint
ruff check .
# Build
python -m build