Scheduled Tasks (Cron) in Hermes Agent: Complete Reference
Schedule tasks in Hermes Agent with natural language or cron expressions, with multi-platform delivery.
This reference page covers the cron (scheduled tasks) feature in Hermes Agent, an AI agent runtime by Nous Research. The cron integration allows you to schedule one-shot or recurring tasks using natural language or cron expressions, with delivery of results to multiple platforms, skill attachments, and a no-agent script-only mode.
What It Does
- Schedule one-shot or recurring tasks with natural language or cron expressions.
- Pause, resume, edit, trigger, and remove jobs without deleting and recreating them.
- Attach zero, one, or multiple skills to a job, so the agent inherits reusable workflows.
- Deliver results back to the origin chat, local files, or configured platform targets (Telegram, Discord, Slack, WhatsApp, Signal, Matrix, Mattermost, email, SMS, Home Assistant, DingTalk, Feishu/Lark, WeCom, Weixin/WeChat, BlueBubbles/iMessage, QQ Bot, and more).
- Run jobs in fresh agent sessions with the normal static tool list.
- Run jobs in no-agent mode, a script on a schedule, its stdout delivered verbatim, with zero LLM involvement.
- Chain jobs together so one job's output is automatically prepended as context for the next job.
- Manage the entire lifecycle through the unified
cronjobtool, accessible via chat, CLI, or natural conversation.
All of this is available to Hermes itself through the cronjob tool, so you can create, pause, edit, and remove jobs by asking in plain language, no CLI required.
Setup
Cron execution is handled by the gateway daemon. The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions.
Install the Gateway
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
Verify Cron Status
hermes cron list
hermes cron status
Configuration File
Cron-specific settings go in ~/.hermes/config.yaml:
cron:
wrap_response: false # default: true
mirror_delivery: false # default: false
script_timeout_seconds: 1800 # default: 3600
Environment Variables
HERMES_CRON_SCRIPT_TIMEOUT: overridescron.script_timeout_secondsand the 3600s default.TELEGRAM_CRON_THREAD_ID: when Telegram topic mode is enabled, point cron deliveries at a dedicated forum topic instead of the root DM.
Authentication for Unattended Runs
hermes setup --portal is the lowest-friction option for unattended runs since OAuth refresh is automatic. See Nous Portal.
Configuration Reference
Cron Job Creation Parameters
| Parameter | Type | Description | Default |
|---|---|---|---|
action | string | Operation to perform: create, update, pause, resume, run, remove, list | required |
schedule | string | Cron expression or natural language (e.g. "every 2h", "0 9 * * *") | required for create |
prompt | string | Task instruction for the agent | required for LLM jobs |
skill / skills | string or list | One or more skill names to load before the prompt | empty |
name | string | Human-readable name for the job (not unique) | auto-generated |
deliver | string | Delivery target (see Delivery Options table) | "origin" on messaging platforms, "local" on CLI |
workdir | string | Absolute path to a project directory; loads AGENTS.md, CLAUDE.md, .cursorrules | empty (detached) |
no_agent | boolean | If true, runs script-only mode with zero LLM involvement | false |
script | string | Filename of a script in ~/.hermes/scripts/ to run | empty |
context_from | string or list | Job ID(s) whose most recent output is prepended as context | empty |
attach_to_session | boolean | Makes the delivery continuable (overrides cron.mirror_delivery) | false |
provider | string | Explicit model provider (e.g. "openai", "anthropic") | global default |
model | string | Explicit model name (e.g. "gpt-4", "claude-3-opus") | global default |
Delivery Options
| Value | 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) | |
"all" | Fan out to every connected home channel | Resolved at fire time |
"telegram,discord" | Fan out to a specific set of channels | Comma-separated list |
"origin,all" | Deliver to the origin plus every other connected channel | Combine any tokens |
Lifecycle Commands
| Command | Description |
|---|---|
hermes cron list | List all cron jobs |
hermes cron pause <job_id_or_name> | Keep the job but stop scheduling it |
hermes cron resume <job_id_or_name> | Re-enable the job and compute the next future run |
hermes cron run <job_id_or_name> | Trigger the job on the next scheduler tick |
hermes cron remove <job_id_or_name> | Delete the job entirely |
hermes cron edit <job_id_or_name> [flags] | Modify schedule, prompt, delivery, etc. |
hermes cron status | Show gateway scheduler status |
hermes cron tick | Force a scheduler tick (useful for testing) |
hermes cron runs [job-id] --limit 20 | Show execution history (alias: history) |
Edit Flags
| Flag | Description |
|---|---|
--schedule | Change the schedule expression |
--prompt | Change the task prompt |
--skill | Replace the attached skill list (repeatable) |
--add-skill | Append to the existing skill list |
--remove-skill | Remove specific attached skills |
--clear-skills | Remove all attached skills |
--workdir | Change the working directory (pass "" to clear) |
How It Works
Gateway Scheduler Behavior
On each tick (every 60 seconds), 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.
Execution History
Hermes records each claimed cron attempt in the profile-local ~/.hermes/cron/executions.db before executor or provider dispatch. Attempts move through claimed, running, and one immutable terminal state: completed, failed, or unknown. After restart, Hermes marks an abandoned attempt unknown only when the original PID and process-start fingerprint prove that its owner is gone. Unknown attempts are audit records and are never automatically rerun. Inspect recent attempts with hermes cron runs [job-id] --limit 20 (alias: history). Terminal history is bounded; active attempts are never pruned. The ledger is included in quick backups.
Job Serialization for Workdir Jobs
Jobs with a workdir run sequentially on the scheduler tick, not in the parallel pool. This is deliberate: the cron worker applies the job workdir through process-global terminal state, so two workdir jobs running at the same time would corrupt each other's cwd. Workdir-less jobs still run in parallel as before.
Provider/Model Snapshots and Job Failure
At creation, an unpinned job (one you don't give an explicit provider/model) follows the global default selected by hermes model, and Hermes snapshots that provider and model on the job. If the global default later changes, the job fails closed: it skips the run, makes no inference call, and sends an alert telling you to pin the provider/model explicitly (cronjob action=update job_id=… provider=… model=…) to proceed. This prevents an unattended job from silently inheriting a switch to a paid provider/model and spending money you didn't intend. To make a job deliberately track your global default, pin it to the new values after changing them.
Cron-Run Session Restrictions
Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops.
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).
The cronjob tool's schema exposes no_agent to Hermes directly, so you can describe a watchdog in chat and let the agent wire it up:
Ping me on Telegram if RAM is over 85%, every 5 minutes.
Hermes will write the check script to ~/.hermes/scripts/ via write_file, then call:
cronjob (
action = "create",
schedule = "every 5m",
script = "memory-watchdog.sh",
no_agent = True,
deliver = "telegram",
name = "memory-watchdog"
)
It picks no_agent=True automatically when the message content is fully determined by the script (watchdogs, threshold alerts, heartbeats).
Chaining Jobs with context_from
Cron jobs run in isolated sessions with no memory of previous runs. But sometimes one job's output is exactly what the next job needs. The context_from parameter wires that connection automatically, Job B's prompt gets Job A's most recent output prepended as context at runtime.
# Job 1: Collect raw data
cronjob (
action = "create",
prompt = "Fetch the top 10 AI/ML stories from Hacker News. Save them to ~/.hermes/data/briefs/raw.md in markdown format with title, URL, and score.",
schedule = "0 7 * * *",
name = "AI News Collector",
)
# Job 2: Triage, receives Job 1's output as context
# Get Job 1's ID from: cronjob(action="list")
cronjob (
action = "create",
prompt = "Read ~/.hermes/data/briefs/raw.md. Score each story 1, 10 for engagement potential and novelty. Output the top 5 to ~/.hermes/data/briefs/ranked.md.",
schedule = "30 7 * * *",
context_from = "<job1_id>",
name = "AI News Triage",
)
# Job 3: Ship, receives Job 2's output as context
cronjob (
action = "create",
prompt = "Read ~/.hermes/data/briefs/ranked.md. Write 3 tweet drafts (hook + body + hashtags). Deliver to telegram:7976161601.",
schedule = "0 8 * * *",
context_from = "<job2_id>",
name = "AI News Brief",
)
How it works:
- When Job 2 fires, Hermes reads Job 1's most recent output from
~/.hermes/cron/output/{job1_id}/*.md. - That output is prepended to Job 2's prompt automatically.
- Job 2 doesn't need to hardcode "read this file", it receives the content as context.
- The chain can be any length: Job 1 → Job 2 → Job 3 → ...
What context_from accepts:
- Single job ID (string):
context_from="a1b2c3d4" - Multiple job IDs (list):
context_from=["a1b2c3d4", "e5f6g7h8"]
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
-------------
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
Continuable Jobs (Reply to a Cron Delivery)
By default a cron delivery is fire-and-forget: the message is sent, but it does not live in the chat's conversation history, so if you reply to it the agent has no record of what it said. Set a job continuable and the delivered brief becomes a conversation you can reply into, the agent has the brief in context instead of asking "what is Task #2?". Opt-in, default off. Enable globally in config, or per-job via the cronjob tool's attach_to_session (which overrides the global setting for that one job):
# ~/.hermes/config.yaml
cron:
mirror_delivery: false # set true to make cron deliveries continuable
Behaviour is thread-preferred, scoped to the job's origin chat:
- Thread-capable platforms (Telegram topics, Discord/Slack threads): each delivery opens its own dedicated thread and the brief is seeded into that thread's session, so a reply in-thread continues with full context. A recurring job (e.g. a daily brief) opens a fresh thread per run, keeping each delivery's follow-up discussion isolated.
- DM-only platforms (WhatsApp, Signal, SMS): no threads exist, so the brief is mirrored into the origin DM session instead, the DM itself is the continuation surface.
Only the origin chat is ever touched: fan-out / broadcast targets (all, explicit other-chat deliveries) are never made continuable. The mirror is written as a labelled user turn ([Cron delivery: <job name>]), which keeps the conversation history alternation-safe across all model providers.
Flat, In-Channel Continuation (Slack)
The thread-preferred behaviour above mints a dedicated thread on every delivery. If you'd rather have a continuable job land flat in the channel timeline, no thread, set the Slack continuable surface to in_channel:
# ~/.hermes/config.yaml
slack:
cron_continuable_surface: in_channel # default: thread
reply_in_thread: false # required pairing (see below)
require_mention: false # so a plain reply continues the job
In in_channel mode the brief is delivered as an ordinary top-level channel message (no thread is opened), and your reply continues the job via the channel's shared session.
Three settings work together:
cron_continuable_surface: in_channel, skips thread creation on delivery.reply_in_thread: false(required), makes the bot answer your reply flat in the channel and key it to the same whole-channel session the brief was seeded into. Without it the continuation still works but arrives in a thread (it falls back safely to thread-style continuation, never a dropped reply, the gateway logs a warning at startup so you can spot the mismatch).require_mention: false(or add the channel tofree_response_channels), so you can reply with a plain message; otherwise the bot only wakes when you@-mention it on each reply.
Because the continuation is the whole-channel session, it is shared: other chatter in the channel, and a second continuable in-channel job, join the same rolling conversation. That is inherent to "flat in a channel" and is the same tradeoff reply_in_thread: false users already accept; use the default thread surface when you want each delivery's follow-up isolated.
This is a Slack capability today. Other platforms accept the key but fall back to the thread surface (their continuation primitives differ); the choice is per-platform, set under each platform's config. It's a gateway-side config flag, a /restart picks it up; no Slack app reinstall is needed.
1:1 DMs: cron_continuable_surface is a channel setting, a 1:1 DM has no thread-vs-timeline split to choose between (the DM is already flat), so the key has no effect there. What governs whether a DM cron delivery is continuable is the separate, pre-existing knob slack.dm_top_level_threads_as_sessions:
false, all top-level DMs share one rolling DM session, so a continuable cron brief and your reply land in the same session and the job continues in context. This is what you want for continuable cron in a DM.true(default), each top-level DM message is its own session, so a reply to a delivered brief starts a fresh session that has no record of the brief. Continuation does not work in this mode (for cron or any other flat delivery).
So for a continuable cron job delivered to a 1:1 DM, set slack.dm_top_level_threads_as_sessions: false. cron_continuable_surface is not required (and is ignored) for DMs.
Silent Suppression
If the agent's final response contains [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. For quiet monitoring jobs, prompt the agent to reply with only [SILENT] when there is nothing to report.
Requirements and Limitations
Prerequisites
- Hermes Agent gateway must be installed and running.
- For unattended runs,
hermes setup --portalis recommended for automatic OAuth refresh. - Scripts for no-agent mode must live in
~/.hermes/scripts/. - Delivery targets require the corresponding platform to be configured (e.g.,
TELEGRAM_HOME_CHANNELfor Telegram).
Limitations
- Cron-run sessions cannot recursively create more cron jobs. Hermes disables cron management tools inside cron executions to prevent runaway scheduling loops.
- Jobs with a
workdirrun sequentially on the scheduler tick, not in the parallel pool, to prevent cwd corruption. - Names are not unique. If a name matches more than one job, the command refuses and prints the candidate IDs so you can disambiguate.
- An unpinned job (no explicit
provider/model) snapshots the global default at creation. If the global default later changes, the job fails closed and sends an alert. It does not silently inherit the new default. - The
context_fromparameter only works with job IDs, not names. - Continuable jobs only affect the origin chat; fan-out/broadcast targets are never made continuable.
- Flat in-channel continuation is currently a Slack capability. Other platforms fall back to thread surface.
- Script timeout default is 3600 seconds (1 hour). This bounds the script only, skill-based/LLM-driven jobs run on a separate inactivity budget and are not capped by this value.
alldelivery expands to every platform with a configured home channel at fire time. Zero is fine; the job simply produces no delivery targets and is recorded as a delivery failure upstream.- Unknown attempts (abandoned after restart) are audit records and are never automatically rerun.
Troubleshooting
Job fails after changing global model provider
If an unpinned job fails after you change the global default provider/model, Hermes sends an alert telling you to pin the provider/model explicitly. Fix it with:
cronjob action=update job_id=<id> provider=<provider> model=<model>
Or via CLI:
hermes cron edit <job_id> --provider <provider> --model <model>
Ambiguous name match when editing/pausing/resuming
If two jobs share the same name, the command refuses and prints the candidate IDs. Use the exact job ID instead of the name.
Telegram cron delivery lands in wrong chat
When Telegram topic mode is enabled, the root DM is reserved as a system lobby. Replies sent there are rebuffed with a lobby reminder and reply_to_message_id is dropped. Point cron at a dedicated forum topic:
- In Telegram, open the bot DM and create a topic named e.g.
Cron. - Long-press the topic header → Copy link; the trailing integer is the topic's
message_thread_id. - Set
TELEGRAM_CRON_THREAD_ID=<thread_id>in your.env.
Explicit deliver="telegram:chat_id:thread_id" targets continue to win over the env var.
Slack continuable job not continuing
If a continuable cron job delivered to a Slack DM doesn't continue when you reply, check slack.dm_top_level_threads_as_sessions:
- Set it to
falsefor DMs. - For channels, ensure
cron_continuable_surface: in_channelis paired withreply_in_thread: falseandrequire_mention: false.
Script timeout
If your script times out, increase the timeout in config:
cron:
script_timeout_seconds: 1800 # 30 minutes
Or set the HERMES_CRON_SCRIPT_TIMEOUT environment variable. Resolution order: env var → config.yaml → 3600s default.
No-agent script fails silently
If a no-agent script exits with a non-zero code or times out, an error alert is delivered. Check the script's stderr and ensure it lives in ~/.hermes/scripts/.
Overlapping scheduler ticks
A file lock at ~/.hermes/cron/.tick.lock prevents overlapping scheduler ticks from double-running the same job batch. If you see warnings about lock contention, ensure only one gateway process is running.
Workdir jobs not running in parallel
Jobs with a workdir run sequentially on the scheduler tick. This is by design to prevent cwd corruption. If you need parallel execution, remove the workdir parameter.
Sources & References
This page was researched from 1 independent source, combined and verified for completeness.
- 1.Scheduled Tasks (Cron)Official documentation · primary source
The #1 AI Newsletter
The most important ai updates, guides, and fixes — one weekly email.
No spam, unsubscribe anytime. Privacy policy