How the Hermes agent does it, and how it is now incorporated into KISS Sorcar — a database-free implementation in one new module.
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:
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:
| Form | Example | Meaning |
|---|---|---|
| Interval | every 30m, every 2h | Repeats on a fixed period |
| Cron expression | 0 9 * * 1-5 | Standard 5-field cron (weekdays 9:00) |
| One-shot duration | 30m, 1d | Fires once, that far from now |
| One-shot timestamp | 2030-01-15T14:00:00 | Fires once at an absolute time |
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.
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.
cronjob toolset is disabled inside cron-run
sessions so a job cannot recursively schedule more jobs.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.
Everything lives in one new module plus three small wirings. No database is used anywhere — the store is a plain JSON file.
| File | Change |
|---|---|
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. |
| Hermes | KISS Sorcar equivalent |
|---|---|
cronjob tool | cron_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 job | A 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 mode | command jobs: shell command, stdout delivered
verbatim, empty stdout = silent, no LLM cost. |
_deliver_result | Generic: 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] suppression | Module-local _is_silent()
(same Hermes-style silence tokens: [SILENT], NO_REPLY; an empty
successful summary is silent too). |
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")
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)
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').*; scan horizon extended past the leap-day gap
(0 0 29 2 * now resolves to the next Feb 29) with day-level skipping so
impossible dates stay fast; unique temp files for atomic saves; backend
disconnect() in finally; delivery failures can no longer escape
_execute_job; a malformed job is disabled and skipped instead of aborting the
whole tick; resuming an already-completed one-shot returns a clear error; docs corrected
(slack:general, not slack:#general).test_agent_path.py::test_missing_getters_keep_passed_and_default_values
(macOS /var vs /private/var symlink assertion), confirmed present
without these changes.