* feat(cron): add no_agent mode for script-only cron jobs (watchdog pattern)
Adds a no_agent=True option to the cronjob system. When enabled, the
scheduler runs the attached script on schedule and delivers its stdout
directly to the job's target — no LLM, no agent loop, no token spend.
This is the classic bash-watchdog pattern (memory alert every 5 min,
disk alert every 15 min, CI ping) reimplemented as a first-class Hermes
primitive instead of a systemd timer + curl + bot token triplet living
outside the system.
## What
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name memory-watchdog
Agent tool:
cronjob(action='create',
schedule='every 5m',
script='memory-watchdog.sh',
no_agent=True,
deliver='telegram')
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message
- Empty stdout → silent tick (no delivery; watchdog pattern)
- wakeAgent=false gate → silent tick (same gate LLM jobs use)
- Non-zero exit/timeout → delivered as an error alert
(broken watchdogs shouldn't fail silently)
- No LLM ever invoked; no tokens spent; no provider fallback applied
## Implementation
cron/jobs.py
* create_job gains no_agent: bool = False
* prompt becomes Optional (no_agent jobs don't need one)
* Validation: no_agent=True requires a script at create time
* Field roundtrips via load_jobs / save_jobs / update_job
cron/scheduler.py
* run_job: new short-circuit branch at the top that runs the script,
wraps its output into the (success, doc, final_response, error)
tuple downstream delivery already expects, and returns before any
AIAgent import or construction
* _run_job_script: picks interpreter by extension — .sh/.bash run
under /bin/bash, anything else under sys.executable (Python).
Shell support unlocks the bash-watchdog pattern without wrapping
scripts in Python. Extension is explicit; we deliberately do NOT
trust the file's own shebang. Path-containment guard (scripts dir)
unchanged.
tools/cronjob_tools.py
* Schema: new no_agent boolean property with clear trigger guidance
* cronjob() accepts no_agent and validates mode-specific shape:
- no_agent=True requires script; prompt/skills optional
- no_agent=False keeps the existing 'prompt or skill required' rule
* update path rejects flipping no_agent=True on a job without a script
* _format_job surfaces no_agent in list output
* Handler lambda forwards no_agent from tool args
hermes_cli/main.py, hermes_cli/cron.py
* 'hermes cron create --no-agent' and edit's --no-agent / --agent
pair for toggling at CLI parity with the agent tool
* Existing --script help text updated to describe both modes
* List / create / edit output now shows 'Mode: no-agent (...)' when set
## Tests
tests/cron/test_cron_no_agent.py — 18 tests covering:
* create_job: no_agent shape, validation, field persistence
* update_job: flag roundtrip across reload
* cronjob tool: schema validation, update toggling, mode-specific
requirements, prompt-relaxation rule
* run_job short-circuit:
- success path delivers stdout verbatim
- empty stdout → SILENT_MARKER (no delivery downstream)
- wakeAgent=false gate → silent
- script failure → error alert
- run_job does NOT import AIAgent (verified via mock)
* _run_job_script:
- .sh executes via bash (no shebang required)
- .bash executes via bash
- .py still runs via sys.executable (regression)
- path-traversal still blocked (security regression)
All 18 new tests pass. 341/342 pre-existing cron tests still pass; the
one failure (test_script_empty_output_noted) was already broken on main
and is unrelated to this change.
## Docs
website/docs/guides/cron-script-only.md — new dedicated guide covering
the watchdog pattern, interpreter rules, delivery mapping, worked
examples (memory / disk alerts), and the comparison table vs hermes send,
regular LLM cron jobs, and OS-level cron.
website/docs/user-guide/features/cron.md — new 'No-agent mode' section
in the cron feature reference, cross-linked to the guide.
website/docs/guides/automate-with-cron.md — new tip box pointing users
to no-agent mode when they don't need LLM reasoning.
## Compatibility
- Existing jobs: unchanged. no_agent defaults to False, existing code
paths untouched until the flag is set.
- Schema additive only; older jobs.json without the field load fine
via .get() with False default.
- New CLI flags are opt-in and don't alter existing flag behavior.
* fix(cron): lazy-import AIAgent + SessionDB so no_agent ticks pay zero
The unconditional `from run_agent import AIAgent` + SessionDB() init at
the top of run_job() meant every no_agent tick still paid the full agent
module load cost (~300ms + transitive imports + DB open) even though it
never touched any of that machinery.
Move both to live under the default (LLM) path, after the no_agent
short-circuit has returned. Now a no_agent tick's sys.modules stays
clean — verified end-to-end:
assert 'run_agent' not in sys.modules # before
run_job(no_agent_job)
assert 'run_agent' not in sys.modules # after
The existing mock-based unit test (test_run_job_no_agent_never_invokes_aiagent)
kept passing because patch() replaces the class AFTER import; the leak
was only visible via real subprocess-style verification. End-to-end
demo confirmed: agent calls cronjob(no_agent=True) → script runs →
stdout delivered → no LLM machinery loaded.
* docs(cron): tighten no_agent tool schema — defaults, silent semantics, pick rule
Previous description buried the important bits in one long sentence.
Agents could plausibly miss three things an LLM-facing schema should
make unmissable:
1. What the default is — now first sentence + JSON Schema `default: false`
2. What 'silent run' actually means for the user — now spelled out:
'nothing is sent to the user and they won't see anything happened'
3. When to pick True vs False — now a concrete decision rule with
examples on both sides (watchdogs/metrics/pollers → True;
summarize/draft/pick/rephrase → False)
Also adds explicit 'prompt and skills are ignored when True' since the
agent could otherwise still pass them out of habit.
No behavior change — schema text only.
17 KiB
| sidebar_position | title | description |
|---|---|---|
| 5 | Scheduled Tasks (Cron) | Schedule automated tasks with natural language, manage them with one cron tool, and attach one or more skills |
Scheduled Tasks (Cron)
Schedule tasks to run automatically with natural language or cron expressions. Hermes exposes cron management through a single cronjob tool with action-style operations instead of separate schedule/list/remove tools.
What cron can do now
Cron jobs can:
- schedule one-shot or recurring tasks
- pause, resume, edit, trigger, and remove jobs
- attach zero, one, or multiple skills to a job
- deliver results back to the origin chat, local files, or configured platform targets
- run in fresh agent sessions with the normal static tool list
:::warning Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops. :::
Creating scheduled tasks
In chat with /cron
/cron add 30m "Remind me to check the build"
/cron add "every 2h" "Check server status"
/cron add "every 1h" "Summarize new feed items" --skill blogwatcher
/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill maps
From the standalone CLI
hermes cron create "every 2h" "Check server status"
hermes cron create "every 1h" "Summarize new feed items" --skill blogwatcher
hermes cron create "every 1h" "Use both skills and combine the result" \
--skill blogwatcher \
--skill maps \
--name "Skill combo"
Through natural conversation
Ask Hermes normally:
Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram.
Hermes will use the unified cronjob tool internally.
Skill-backed cron jobs
A cron job can load one or more skills before it runs the prompt.
Single skill
cronjob(
action="create",
skill="blogwatcher",
prompt="Check the configured feeds and summarize anything new.",
schedule="0 9 * * *",
name="Morning feeds",
)
Multiple skills
Skills are loaded in order. The prompt becomes the task instruction layered on top of those skills.
cronjob(
action="create",
skills=["blogwatcher", "maps"],
prompt="Look for new local events and interesting nearby places, then combine them into one short brief.",
schedule="every 6h",
name="Local brief",
)
This is useful when you want a scheduled agent to inherit reusable workflows without stuffing the full skill text into the cron prompt itself.
Running a job inside a project directory
Cron jobs default to running detached from any repo — no AGENTS.md, CLAUDE.md, or .cursorrules is loaded, and the terminal / file / code-exec tools run from whatever working directory the gateway started in. Pass --workdir (CLI) or workdir= (tool call) to change that:
# Standalone CLI (schedule and prompt are positional)
hermes cron create "every 1d at 09:00" \
"Audit open PRs, summarize CI health, and post to #eng" \
--workdir /home/me/projects/acme
# From a chat, via the cronjob tool
cronjob(
action="create",
schedule="every 1d at 09:00",
workdir="/home/me/projects/acme",
prompt="Audit open PRs, summarize CI health, and post to #eng",
)
When workdir is set:
AGENTS.md,CLAUDE.md, and.cursorrulesfrom that directory are injected into the system prompt (same discovery order as the interactive CLI)terminal,read_file,write_file,patch,search_files, andexecute_codeall use that directory as their working directory (viaTERMINAL_CWD)- The path must be an absolute directory that exists — relative paths and missing directories are rejected at create / update time
- Pass
--workdir ""(orworkdir=""via the tool) on edit to clear it and restore the old behaviour
:::note Serialization
Jobs with a workdir run sequentially on the scheduler tick, not in the parallel pool. This is deliberate — TERMINAL_CWD is process-global, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before.
:::
Editing jobs
You do not need to delete and recreate jobs just to change them.
Chat
/cron edit <job_id> --schedule "every 4h"
/cron edit <job_id> --prompt "Use the revised task"
/cron edit <job_id> --skill blogwatcher --skill maps
/cron edit <job_id> --remove-skill blogwatcher
/cron edit <job_id> --clear-skills
Standalone CLI
hermes cron edit <job_id> --schedule "every 4h"
hermes cron edit <job_id> --prompt "Use the revised task"
hermes cron edit <job_id> --skill blogwatcher --skill maps
hermes cron edit <job_id> --add-skill maps
hermes cron edit <job_id> --remove-skill blogwatcher
hermes cron edit <job_id> --clear-skills
Notes:
- repeated
--skillreplaces the job's attached skill list --add-skillappends to the existing list without replacing it--remove-skillremoves specific attached skills--clear-skillsremoves all attached skills
Lifecycle actions
Cron jobs now have a fuller lifecycle than just create/remove.
Chat
/cron list
/cron pause <job_id>
/cron resume <job_id>
/cron run <job_id>
/cron remove <job_id>
Standalone CLI
hermes cron list
hermes cron pause <job_id>
hermes cron resume <job_id>
hermes cron run <job_id>
hermes cron remove <job_id>
hermes cron status
hermes cron tick
What they do:
pause— keep the job but stop scheduling itresume— re-enable the job and compute the next future runrun— trigger the job on the next scheduler tickremove— delete it entirely
How it works
Cron execution is handled by the gateway daemon. The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions.
hermes gateway install # Install as a user service
sudo hermes gateway install --system # Linux: boot-time system service for servers
hermes gateway # Or run in foreground
hermes cron list
hermes cron status
Gateway scheduler behavior
On each tick Hermes:
- loads jobs from
~/.hermes/cron/jobs.json - checks
next_run_atagainst the current time - starts a fresh
AIAgentsession for each due job - optionally injects one or more attached skills into that fresh session
- runs the prompt to completion
- delivers the final response
- updates run metadata and the next scheduled time
A file lock at ~/.hermes/cron/.tick.lock prevents overlapping scheduler ticks from double-running the same job batch.
Delivery options
When scheduling jobs, you specify where the output goes:
| Option | Description | Example |
|---|---|---|
"origin" |
Back to where the job was created | Default on messaging platforms |
"local" |
Save to local files only (~/.hermes/cron/output/) |
Default on CLI |
"telegram" |
Telegram home channel | Uses TELEGRAM_HOME_CHANNEL |
"telegram:123456" |
Specific Telegram chat by ID | Direct delivery |
"telegram:-100123:17585" |
Specific Telegram topic | chat_id:thread_id format |
"discord" |
Discord home channel | Uses DISCORD_HOME_CHANNEL |
"discord:#engineering" |
Specific Discord channel | By channel name |
"slack" |
Slack home channel | |
"whatsapp" |
WhatsApp home | |
"signal" |
Signal | |
"matrix" |
Matrix home room | |
"mattermost" |
Mattermost home channel | |
"email" |
||
"sms" |
SMS via Twilio | |
"homeassistant" |
Home Assistant | |
"dingtalk" |
DingTalk | |
"feishu" |
Feishu/Lark | |
"wecom" |
WeCom | |
"weixin" |
Weixin (WeChat) | |
"bluebubbles" |
BlueBubbles (iMessage) | |
"qqbot" |
QQ Bot (Tencent QQ) |
The agent's final response is automatically delivered. You do not need to call send_message in the cron prompt.
Response wrapping
By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task:
Cronjob Response: Morning feeds
-------------
<agent output here>
Note: The agent cannot see this message, and therefore cannot respond to it.
To deliver the raw agent output without the wrapper, set cron.wrap_response to false:
# ~/.hermes/config.yaml
cron:
wrap_response: false
Silent suppression
If the agent's final response starts with [SILENT], delivery is suppressed entirely. The output is still saved locally for audit (in ~/.hermes/cron/output/), but no message is sent to the delivery target.
This is useful for monitoring jobs that should only report when something is wrong:
Check if nginx is running. If everything is healthy, respond with only [SILENT].
Otherwise, report the issue.
Failed jobs always deliver regardless of the [SILENT] marker — only successful runs can be silenced.
Script timeout
Pre-run scripts (attached via the script parameter) have a default timeout of 120 seconds. If your scripts need longer — for example, to include randomized delays that avoid bot-like timing patterns — you can increase this:
# ~/.hermes/config.yaml
cron:
script_timeout_seconds: 300 # 5 minutes
Or set the HERMES_CRON_SCRIPT_TIMEOUT environment variable. The resolution order is: env var → config.yaml → 120s default.
No-agent mode (script-only jobs)
For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass no_agent=True at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely:
hermes cron create "every 5m" \
--no-agent \
--script memory-watchdog.sh \
--deliver telegram \
--name "memory-watchdog"
Semantics:
- Script stdout (trimmed) → delivered verbatim as the message.
- Empty stdout → silent tick, no delivery. This is the watchdog pattern: "only say something when something is wrong".
- Non-zero exit or timeout → an error alert is delivered, so a broken watchdog can't fail silently.
{"wakeAgent": false}on the last line → silent tick (same gate LLM jobs use).- No tokens, no model, no provider fallback — the job never touches the inference layer.
.sh / .bash files run under /bin/bash; anything else under the current Python interpreter (sys.executable). Scripts must live in ~/.hermes/scripts/ (same sandboxing rule as the pre-run script gate).
See the Script-Only Cron Jobs guide for worked examples.
Provider recovery
Cron jobs inherit your configured fallback providers and credential pool rotation. If the primary API key is rate-limited or the provider returns an error, the cron agent can:
- Fall back to an alternate provider if you have
fallback_providers(or the legacyfallback_model) configured inconfig.yaml - Rotate to the next credential in your credential pool for the same provider
This means cron jobs that run at high frequency or during peak hours are more resilient — a single rate-limited key won't fail the entire run.
Schedule formats
The agent's final response is automatically delivered — you do not need to include send_message in the cron prompt for that same destination. If a cron run calls send_message to the exact target the scheduler will already deliver to, Hermes skips that duplicate send and tells the model to put the user-facing content in the final response instead. Use send_message only for additional or different targets.
Relative delays (one-shot)
30m → Run once in 30 minutes
2h → Run once in 2 hours
1d → Run once in 1 day
Intervals (recurring)
every 30m → Every 30 minutes
every 2h → Every 2 hours
every 1d → Every day
Cron expressions
0 9 * * * → Daily at 9:00 AM
0 9 * * 1-5 → Weekdays at 9:00 AM
0 */6 * * * → Every 6 hours
30 8 1 * * → First of every month at 8:30 AM
0 0 * * 0 → Every Sunday at midnight
ISO timestamps
2026-03-15T09:00:00 → One-time at March 15, 2026 9:00 AM
Repeat behavior
| Schedule type | Default repeat | Behavior |
|---|---|---|
One-shot (30m, timestamp) |
1 | Runs once |
Interval (every 2h) |
forever | Runs until removed |
| Cron expression | forever | Runs until removed |
You can override it:
cronjob(
action="create",
prompt="...",
schedule="every 2h",
repeat=5,
)
Managing jobs programmatically
The agent-facing API is one tool:
cronjob(action="create", ...)
cronjob(action="list")
cronjob(action="update", job_id="...")
cronjob(action="pause", job_id="...")
cronjob(action="resume", job_id="...")
cronjob(action="run", job_id="...")
cronjob(action="remove", job_id="...")
For update, pass skills=[] to remove all attached skills.
Toolsets available to cron jobs
Cron runs each job in a fresh agent session with no chat platform attached. By default the cron agent gets the toolset you configured for the cron platform in hermes tools — not the CLI default, not everything under the sun.
hermes tools
# → pick the "cron" platform in the curses UI
# → toggle toolsets on/off just like you would for Telegram/Discord/etc.
Tighter per-job control is available via the enabled_toolsets field on cronjob.create (or on an existing job via cronjob.update):
cronjob(action="create", name="weekly-news-summary",
schedule="every sunday 9am",
enabled_toolsets=["web", "file"], # just web + file, no terminal/browser/etc.
prompt="Summarize this week's AI news: ...")
When enabled_toolsets is set on a job it wins; otherwise the hermes tools cron-platform config wins; otherwise Hermes falls back to the built-in defaults. This matters for cost control: carrying moa, browser, delegation into every tiny "fetch news" job bloats the tool-schema prompt on every LLM call.
Skipping the agent entirely: wakeAgent
If your cron job attaches a pre-check script (via script=), the script can decide at runtime whether Hermes should even invoke the agent. Emit a final stdout line of the form:
{"wakeAgent": false}
…and cron skips the agent run entirely for this tick. Useful for frequent polls (every 1–5 min) that only need to wake the LLM when state actually changed — otherwise you pay for zero-content agent turns over and over.
# pre-check script
import json, sys
latest = fetch_latest_issue_count()
prev = read_state("issue_count")
if latest == prev:
print(json.dumps({"wakeAgent": False})) # skip this tick
sys.exit(0)
write_state("issue_count", latest)
print(json.dumps({"wakeAgent": True, "context": {"new_issues": latest - prev}}))
When wakeAgent is omitted, the default is true (wake the agent as usual).
Chaining jobs: context_from
A cron job can consume the most recent successful output of one or more other jobs by listing their names (or IDs) in context_from:
cronjob(action="create", name="daily-digest",
schedule="every day 7am",
context_from=["ai-news-fetch", "github-prs-fetch"],
prompt="Write the daily digest using the outputs above.")
The referenced jobs' most recent completed outputs are injected above the prompt as context for this run. Each upstream entry must be a valid job ID or name (see cronjob action="list"). Note: chaining reads the most recent completed output — it does not wait for upstream jobs that are running in the same tick.
Job storage
Jobs are stored in ~/.hermes/cron/jobs.json. Output from job runs is saved to ~/.hermes/cron/output/{job_id}/{timestamp}.md.
Jobs may store model and provider as null. When those fields are omitted, Hermes resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set.
The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.
Self-contained prompts still matter
:::warning Important Cron jobs run in a completely fresh agent session. The prompt must contain everything the agent needs that is not already provided by attached skills. :::
BAD: "Check on that server issue"
GOOD: "SSH into server 192.168.1.100 as user 'deploy', check if nginx is running with 'systemctl status nginx', and verify https://example.com returns HTTP 200."
Security
Scheduled task prompts are scanned for prompt-injection and credential-exfiltration patterns at creation and update time. Prompts containing invisible Unicode tricks, SSH backdoor attempts, or obvious secret-exfiltration payloads are blocked.