Natural-Language Scheduled Automations (Cron) with Delivery to Any Channel

How the Hermes agent does it, and how it is now incorporated into KISS Sorcar — a database-free implementation in one new module.

1. How it works in the Hermes agent

The Hermes agent (NousResearch/hermes-agent) lets you say things like "every weekday at 9am, summarize my inbox and send it to my Telegram". Five pieces make that work:

1.1 The LLM does the natural-language parsing, not a parser

There is no natural-language date parser. Hermes exposes a cronjob tool (tools/cronjob_tools.py) with actions create / list / update / pause / resume / trigger / remove, and the tool accepts only four normalized schedule forms. The model itself translates the user's phrasing into one of them:

FormExampleMeaning
Intervalevery 30m, every 2hRepeats on a fixed period
Cron expression0 9 * * 1-5Standard 5-field cron (weekdays 9:00)
One-shot duration30m, 1dFires once, that far from now
One-shot timestamp2030-01-15T14:00:00Fires once at an absolute time

1.2 Storage: a JSON file, not a database

Jobs are plain JSON records in ~/.hermes/cron/jobs.json with fields such as id, name, prompt, schedule, enabled, next_run_at, deliver. This is the key simplicity decision: the whole scheduler state is one human-editable file.

1.3 The scheduler loop

The Hermes gateway daemon calls tick() (in cron/scheduler.py) about every 60 seconds from a background thread. A file lock (~/.hermes/cron/.tick.lock) makes overlapping ticks exit immediately. Each tick: acquire lock → load jobs.json → select jobs with next_run ≤ now → run each → deliver → compute the next run time → write the file back → release the lock.

1.4 Job execution: fresh session, guarded

1.5 Delivery to any channel

After a run, _deliver_result routes the agent's final summary to the job's deliver targets — the agent never calls send_message itself. Targets: local (a file under ~/.hermes/cron/output/), origin (the chat where the job was created), explicit targets like telegram:123456 or discord:#engineering, all (every connected channel), and combinations (origin,all). A summary of exactly [SILENT] suppresses delivery.

User (natural language) "every weekday at 9am, brief me on Telegram" run_agent("cron") → cron agent LLM normalizes schedule: "0 9 * * 1-5" deliver: "telegram:123456" ~/.kiss/cron/jobs.json plain JSON list, flock-guarded atomic tmp+rename writes kiss-web daemon thread tick() every 60 s due = next_run_at ≤ now reschedule BEFORE running Job execution prompt → fresh kiss-web session command → shell, no LLM [SILENT] / empty stdout → no delivery Delivery <channel>_agent._make_backend() .send_message(chat, text) telegram / slack / discord / email / ntfy / sms / ... + local log create read + claim run summary record status
Data flow of the KISS Sorcar implementation (identical in shape to Hermes: tool → JSON store → tick loop → fresh session → channel delivery).

2. What was incorporated into KISS Sorcar

Everything lives in one new module plus three small wirings. No database is used anywhere — the store is a plain JSON file.

FileChange
src/kiss/agents/sorcar/cron_agent.py New. Job store, schedule parser, cron_job tool, tick() scheduler + start_scheduler_thread(), channel delivery, kiss-cron CLI, get_tools() (tools-file contract), and the agent-script getters (get_work_dir(), get_use_worktree(), get_auto_commit()) that make the module dispatchable as run_agent("cron", ...). Self-contained: no imports from kiss.agents.third_party_agents (channel modules are looked up dynamically at delivery time only).
src/kiss/server/web_server.py The kiss-web daemon starts the cron scheduler as a background daemon thread on startup (prompt jobs are submitted back through the daemon's own UDS socket) and stops it on shutdown — no external kiss-cron process or system crontab needed.
src/kiss/agents/sorcar/sorcar_agent.py cron_job is not a built-in tool: a Sorcar chat schedules automations by calling run_agent("cron", task), which dispatches cron_agent.py as an agent script — the dispatched session gets the cron_job tool from get_tools() and runs in ~/.kiss/cron/work with no git lifecycle.
pyproject.toml Console script: kiss-cron = "kiss.agents.sorcar.cron_agent:main".
src/kiss/tests/agents/sorcar/test_cron_agent.py New. 45 end-to-end tests (no mocks): schedule math, tool CRUD, tick execution, silence, delivery errors, lock contention, CLI, scheduler thread, custom-socket run_now, and a real daemon boot that executes a due job and stops the thread on shutdown.

2.1 How each Hermes concept maps onto existing KISS machinery

HermesKISS Sorcar equivalent
cronjob toolcron_job(action, ...) — one tool whose docstring teaches the LLM the four schedule forms and delivery syntax.
~/.hermes/cron/jobs.json~/.kiss/cron/jobs.json, written atomically (mkstemp + rename) and guarded by the module's own _jobs_lock flock helper.
Gateway daemon tick()A background thread inside the kiss-web daemon (60 s loop), started automatically on daemon startup; kiss-cron --daemon / --tick still work standalone. The tick reschedules due jobs before running them, so the same occurrence can never fire twice.
Fresh AIAgent session per jobA fresh daemon session started through the public client API kiss.server.sorcar.run() against the running kiss-web daemon's socket; a preamble marks the run unattended and forbids scheduling more jobs.
No-agent script modecommand jobs: shell command, stdout delivered verbatim, empty stdout = silent, no LLM cost.
_deliver_resultGeneric: for target <channel>:<chat> the module kiss.agents.third_party_agents.<channel>_agent is imported, its existing _make_backend() factory builds an authenticated backend, and send_message() is called. All ~30 pollable channel agents (Telegram, Slack, Discord, email, ntfy, SMS, WhatsApp, Matrix, ...) work unmodified.
[SILENT] suppressionModule-local _is_silent() (same Hermes-style silence tokens: [SILENT], NO_REPLY; an empty successful summary is silent too).

3. Using it

From a Sorcar chat (natural language)

You: every weekday at 9am, summarize Hacker News and send it to my telegram chat 123456
Agent calls: run_agent("cron", task="create a job named 'HN brief' ...")
Cron agent calls: cron_job("create", name="HN brief",
    prompt="Summarize today's top Hacker News stories",
    schedule="0 9 * * 1-5", deliver="telegram:123456")

From the command line

kiss-cron --create "disk check" --schedule "every 6h" \
    --command "df -h / | tail -1" --deliver ntfy
kiss-cron --create "reminder" --schedule "30m" \
    --prompt "Tell me to take a break" --deliver "telegram:123456"
kiss-cron --list          # show all jobs
kiss-cron --run <ID>      # trigger immediately
kiss-cron --pause <ID>  /  --resume <ID>  /  --remove <ID>
kiss-cron --daemon        # optional standalone scheduler (not needed
                          # when the kiss-web daemon is running)
Nothing extra to run: the kiss-web daemon ticks the scheduler automatically in a background thread, so jobs fire whenever the daemon is up. kiss-cron --daemon / --tick remain available for running the scheduler without kiss-web. Delivery to a channel requires that channel agent to be authenticated once (e.g. kiss-telegram -t 'authenticate').

4. Verification and review