Skip to content

Repository files navigation

Docket — Deterministic task runner for AI agents

Deterministic task runner for AI agents & systemd timers

Python 3.11+ MIT License Tests PyPI


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

Why Docket?

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

Install

pip install docket-cli

Dev setup:

git clone https://github.com/bennybuoy/docket.git
cd docket && pip install -e ".[dev]"

Quick start

1. Create a config file:

docket configure

Or 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_id

3. 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

Commands

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.

Agent discovery

Agents should begin with the self-describing contract:

docket --json capabilities

The 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.

MCP adapter

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 observe

The 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 observe

See 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.

Agent run receipts

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

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

Per-task config keys

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.

Config validation

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 strings
  • retry: non-negative integer
  • backoff_base: positive number
  • on_success, on_failure, on_complete: string or list of strings
  • notify: mapping with on, discord, and/or telegram
  • inputs, outputs: mapping of string keys to string descriptions
  • required_env: list of strings
  • network, destructive: booleans

Built-in tasks get extra validation:

  • backup.tool: rclone, rsync, or hbackup
  • backup.source, backup.remote: non-empty strings when present
  • backup.extra_args: list of strings
  • backup.idle_timeout_seconds: non-negative number or null
  • backup.hbackup_command: backup or auto
  • backup.hbackup_bin, backup.output: non-empty strings
  • backup.excludes: list of strings
  • hbackup.action: backup, auto, list, upload, or setup-drive
  • hbackup.hbackup_bin, hbackup.output, hbackup.archive, hbackup.destination, hbackup.drive_remote, hbackup.drive_folder: non-empty strings
  • hbackup.excludes, hbackup.extra_args: list of strings
  • hbackup.drive: boolean
  • hbackup.idle_timeout_seconds: non-negative number or null
  • health-check.checks: list containing disk, memory, and/or systemd-units
  • health-check.*_threshold_pct: number from 0 to 100
  • health-check.systemd_units: list of strings

Unknown keys on custom tasks are allowed so task plugins can define their own configuration contract.

Policy-bound preview and execution

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.

Provenance receipts

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.json

Tasks 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 strings
  • args, dry_run_args: list of strings
  • dry_run_command: string or list of strings
  • output_format: text, json, jsonl, or auto
  • success_exit_codes: list of integers
  • env: mapping with string keys
  • cwd: non-empty string
  • timeout_seconds: positive number

Writing custom tasks

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.

Command tasks

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: false

For 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> --follow

Each 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.

Completion hooks

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.

Native notifications

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_TOKEN

When 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.

Built-in tasks

hbackup

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.

backup

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: 1800

idle_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.

health-check

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]

Exit codes

Code Meaning
0 Success
1 Failure (task error, lock contention, config error)
2 Dry-run completed (no side effects)

Architecture

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

Development

# Install with dev dependencies
pip install -e ".[dev]"

# Run tests
python -m pytest tests/ -q

# Lint
ruff check .

# Build
python -m build

License

MIT

About

Deterministic task runner for AI agents and systemd timers

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages