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 preview <task> |
Resolve exact effects and issue an expiring preview ID |
docket approve <preview_id> |
Operator approval for one exact preview |
docket execute <task> --preview-id <id> |
Execute approved inputs without a force bypass |
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 |
docket mcp |
Run the optional MCP 2.x stdio adapter |
docket receipt show <id> |
Inspect a provenance receipt by receipt or run ID |
docket receipt verify <id> |
Verify receipt chain/signature and current artifacts |
docket receipt export <id> --output <file> |
Export receipt JSON without overwriting files |
docket package inspect <path> |
Validate a task-package manifest and checksums without execution |
docket package install <path> |
Copy a verified package without executing package code |
docket package update <path> |
Preview package permission and contract changes (--apply to update) |
docket package list |
List installed packages and integrity state |
docket package remove <name> --yes |
Remove one exactly named package |
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 versioned contract, invocation and output schemas, examples, defaults,
declared effects, credential names, preview support, resolved config, and
safety metadata:
{
"tool": "docket",
"agent_contract_version": 1,
"commands": [{"name": "run <task> --async", "json": true}],
"tasks": [
{
"name": "hbackup",
"contract_version": "docket.task/v1",
"task_version": "1",
"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"},
"input_schema": {},
"output_schema": {},
"examples": [],
"defaults": {},
"effects": [],
"required_credentials": [],
"preview_supported": true,
"required_env": [],
"network": true,
"destructive": false
}
]
}Use docket --json describe <task> for a focused view before running an
unfamiliar task.
Tasks with an input_schema accept validated invocation-time JSON without a
config edit:
docket --json run health-check --dry-run \
--inputs '{"checks":["disk"],"disk_threshold_pct":85}'The Python API uses run_task("health-check", inputs={...}); MCP exposes the
same object as the inputs field on docket_run_task and
docket_run_task_async. All three routes use JSON Schema 2020-12, apply
declared defaults, reject undeclared fields, and return
contract_version: docket.task/v1. A task's output_schema validates
TaskResult.details; violations become explicit failed results.
Install the optional protocol adapter and register docket-mcp (or docket mcp) as a stdio MCP server:
python -m pip install "docket-cli[mcp]"
docket-mcp --profile observeThe default observe profile has no mutating tools. execute-strict gives a
server-bound worker principal only preview and exact execution; it omits
approval, raw run, async run, and cancel. approve binds one configured
approver and exposes preview inspection plus scoped signed approval, with no
execution tools. The legacy execute profile keeps the broader tool set for
compatibility. No MCP profile exposes the CLI's --force bypass. Destructive
execution still needs the server-side DOCKET_MCP_ALLOW_DESTRUCTIVE=1 opt-in,
and policy remains a separate fail-closed gate.
Hermes one-command setup:
hermes mcp add docket --command docket-mcp --args --profile observeSee the cross-agent integration matrix for Hermes, Codex, Claude Code, OpenClaw, CLI, and Python setup and authority guidance.
See Trusted task packages for the inert install and update-preview contract, and Initial Hermes operations for backup verification, restore rehearsal, gateway diagnostics, configuration diff, service health, and bounded restart.
See Security boundary for the trust model, redaction scope, and the distinction between human and agent-facing execution controls.
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.
All task implementations emit docket.event/v1 lifecycle envelopes:
run.started, progress, artifact.produced, warning,
approval.accepted, run.retry, run.completed,
verification.completed, and escalation.required. Resume without replaying
old events using docket --json events <run_id> --after <event_index> or the
after_cursor API/MCP field.
Terminal results include wake_agent/Hermes-compatible wakeAgent, severity,
reason, evidence, and recommended_action. Deterministic success sets
wakeAgent: false and never invokes the configured bridge. Failures default to
a conservative escalation. Approval-bound async runs use a one-time token bound
to task, inputs, redacted config, and the pre-approved policy decision; the
token is consumed atomically by the child process.
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
locks_dir: ~/.docket/locks # Per-task cross-process lock files
policy:
version: site-v1
approval_risk: high # low, medium, high, or critical
enforce_preview: false # true for strict agent execution profiles
approval_ttl_seconds: 900
allowed_network_domains: [api.example.com]
allowed_filesystem_roots: [/srv/agent-data]
allowed_processes: [hbackup, systemctl]
allowed_services: [hermes.service]
allowed_credentials: [BACKUP_TOKEN]
allow_privilege: true # only in a dedicated bounded-restart profile
approvals:
require_distinct_principal: true
require_requester_principal: true
approvers:
agent:hermes-approver:
signing_key_env: DOCKET_HERMES_APPROVER_KEY # key stays outside YAML
tasks: [hermes-bounded-restart]
max_risk: high
allowed_effect_types: [service, privilege]
allowed_services: [hermes.service]
allow_privilege: true
approval_ttl_seconds: 120
receipts:
signing_key_env: DOCKET_RECEIPT_SIGNING_KEY # optional HMAC key; value is never stored
wake_agent:
adapter: hermes
command: [hermes-wake-bridge, --stdin] # receives JSON only when wakeAgent=true
timeout_seconds: 10
# 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, logs_dir, and locks_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.
Tasks declare structured effects such as filesystem paths, network domains, processes, services, credential names, privilege, cost, duration, concurrency, cooldown, and resources. Docket resolves effect templates only after typed inputs validate, then applies the active profile policy:
docket --json preview my-operation --inputs '{"target":"/srv/agent-data/item"}'
docket --json approve <preview_id> --approved-by operator:alice
docket --json execute my-operation --preview-id <preview_id> \
--inputs '{"target":"/srv/agent-data/item"}'Approvals bind task/contract versions, policy version and hash, exact input and
redacted-config hashes, resolved effects, and expiry. Changed, expired, failed,
denied, unapproved, or previously used previews are rejected. --force cannot
bypass this gate. Human approval remains available through docket approve.
For delegated agents, run a worker with --profile execute-strict --principal agent:worker and a different agent with --profile approve --approver-id agent:approver; scope and sign that approver in configuration. Never expose
both servers or the approver key to the same untrusted agent. See Policy and
approvals.
Every completed synchronous run returns and stores a docket.receipt/v1
receipt. It records caller/session correlation, task and executable hashes,
redacted-config and input hashes, policy/approval, attempts and timing, the
redacted result, declared artifacts and checksums, and verification state.
Receipts form an append-only SHA-256 chain. When receipts.signing_key_env is
configured they also carry an HMAC-SHA256 signature; a missing configured key
blocks execution before task code runs.
docket --json run health-check \
--context '{"caller_id":"agent:hermes","session_id":"session-42"}'
docket receipt show <receipt_id>
docket receipt verify <receipt_id>
docket receipt export <receipt_id> --output receipt.jsonTasks declare artifacts in TaskResult.details.artifacts as paths or objects
with path, optional kind, and optional expected sha256. Docket hashes file
artifacts at completion and verification re-hashes them later. See
Receipts and verification.
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
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