--- sidebar_position: 5 title: "Scheduled Tasks (Cron)" description: "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_manage` 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 - run in **no-agent mode** — a script on a schedule, its stdout delivered verbatim, zero LLM involvement (see the [no-agent mode](#no-agent-mode-script-only-jobs) section below) - fire on **external events** — a webhook route with `cron_job` set fires the job the moment something happens (a PR gets feedback, a service posts an alert) instead of waiting for the next scheduled tick. See [Event-Triggered Cron Jobs](../messaging/webhooks.md#event-triggered-cron-jobs). All of this is available to Hermes itself through the `cronjob_manage` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required. :::tip **Which model does a cron job run on?** Resolution at fire time is: per-job pin → `cron.model` in `config.yaml` → the main agent model from `hermes model`. - **Per-job pin** — a job that carries its own model. Set it to a specific model via the dashboard, `hermes cron create/edit --model … --provider …`, or by editing `~/.hermes/cron/jobs.json`; or **lock in the current main model** with `hermes cron create/edit --pin` (the agent's `cronjob_manage` tool can do this too with `pinned=true`, but only when you ask it to). `--unpin` (`pinned=false`) releases the lock. The agent cannot point a job at a *different* model — inference pins are user-owned. - **`cron.model` / `cron.model_provider`** — a cron-fleet default: every unpinned job runs on this model, independent of your chat model. Set it once (`hermes config set cron.model `) and switching your chat model with `hermes model` or `/model` never touches your cron fleet. - **Main agent model** — when neither of the above is set, a job runs on whatever `hermes model` / `/model` is set to **at the moment it fires**. Change your main model and every unpinned job follows on its next run. Whichever provider a job resolves to, its provider-specific request settings (e.g. `request_overrides` such as `extra_body`/`extra_headers` for custom providers) carry into the scheduled run just like an interactive session. `hermes setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See [Nous Portal](../../integrations/nous-portal.md). ::: :::tip **Per-job reasoning effort.** A job can pin its own thinking level, independent of the model pin: one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. When set, it overrides both the global `agent.reasoning_effort` and per-model `agent.reasoning_overrides` for that job's runs (`none` disables thinking). Set it via `hermes cron create/edit --reasoning-effort high`; pass an empty string on edit to clear the pin and follow config again. (It is deliberately not exposed on the agent's `cronjob_manage` tool — model configuration stays a user decision.) Levels a model doesn't support are clamped or omitted by the provider at request time — pinning `xhigh` on a model that caps at `high` runs at `high`. The pin has no effect on `no_agent` jobs (there is no LLM call to tune). Use it to run heavy scheduled analyses at `high` while cheap recurring jobs run at `minimal`, without touching your global default. ::: :::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` ```bash /cron add "in 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 ```bash 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: ```text Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram. ``` Hermes will use the unified `cronjob_manage` tool internally. ## Pre-dispatch configuration validation Before constructing any agent machinery for a scheduled run, the scheduler validates that the job's configuration can actually produce a successful run: - the provider API key resolves (skipped for an unpinned job when a `fallback_providers` chain is configured, since the fallback path may rescue a missing primary key; a pinned job does not use that chain, so it is always checked), - attached skills are ready (no missing required environment variables, commands, or credential files), - delivery platform targets are known and have gateway credentials configured (`local`/`origin` targets are never checked), - every MCP server the job names in its own `enabled_toolsets` resolved to at least one tool for this profile. A server that connected earlier in this gateway and is only reconnecting after a network blip (router reboot, DNS failure) does **not** block: the job runs with the tools that did resolve and the gateway log notes which servers were skipped (once per outage). A server that never connected for this profile (wrong URL or credentials, or a server another profile owns under a multiplexer), or one parked on a permanent error such as revoked credentials, blocks the run. When validation fails, the job's `last_status` becomes `blocked_config`, ONE alert is delivered (it is not repeated every tick), and **no LLM call is made** — a misconfigured job never spends tokens. The next healthy run clears the blocked state so a future configuration break alerts again. A missing-credential verdict names the profile and `HERMES_HOME` the scheduler read, e.g. `provider credential missing: No Codex credentials stored … [profile 'default', HERMES_HOME /opt/data]`. When an interactive session with "the same" credential works, compare that path with the shell's `HERMES_HOME`: a gateway started without the shell's environment (Docker `HOME` vs `HERMES_HOME`, a service unit) or a multiplexed satellite profile reads a different `auth.json` and `.env` than the shell does. To disable the validation and restore the old behavior (the run proceeds and fails during execution): ```yaml cron: preflight: false ``` Or: `hermes config set cron.preflight false` ## Moving unpinned jobs to a new global default An unpinned job follows the main agent model, so `hermes model` moves your cron fleet with it. When you want a job to *stay* on a model: ```bash hermes cron edit --pin # lock the current main model onto one job hermes cron edit --provider --model # pin an explicit model hermes cron edit --unpin # follow the main model again hermes config set cron.model # every unpinned job, without touching chat ``` `hermes cron list` and the `cronjob_manage` tool report `pinned` per job. ## Skill-backed cron jobs A cron job can load one or more skills before it runs the prompt. Each skill loads exactly as it does from `/skill-name` in a chat session, including the `[Skill config ...]` block with its resolved `metadata.hermes.config` values from `config.yaml`. ### Single skill ```python 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. ```python 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: ```bash # 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 ``` ```python # 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 `.cursorrules` from that directory are injected into the system prompt (same discovery order as the interactive CLI) - `terminal`, `read_file`, `write_file`, `patch`, `search_files`, and `execute_code` all use that directory as their working directory - The path must be an absolute directory that exists — relative paths and missing directories are rejected at create / update time - Pass `--workdir ""` (or `workdir=""` via the tool) on edit to clear it and restore the old behaviour :::note Isolation Each agent run binds its `workdir` to that run's unique task identity. Workdir jobs therefore use the normal parallel pool without mutating process-global terminal state or leaking paths between concurrent runs. Set `cron.max_parallel_jobs` if you want to limit total cron concurrency. ::: ## Editing jobs You do not need to delete and recreate jobs just to change them. :::tip Job reference The `` placeholder below (and in [Lifecycle actions](#lifecycle-actions)) also accepts the job's name (case-insensitive) — handy when you remember `morning-digest` but not the hex ID. An exact job ID takes precedence over name matches; if the reference is not an ID and a name matches more than one job, the command refuses and prints the candidate IDs so you can disambiguate. ::: ### Chat ```bash /cron edit --schedule "every 4h" /cron edit --prompt "Use the revised task" /cron edit --skill blogwatcher --skill maps /cron edit --remove-skill blogwatcher /cron edit --clear-skills ``` ### Standalone CLI ```bash hermes cron edit --schedule "every 4h" hermes cron edit --prompt "Use the revised task" hermes cron edit --skill blogwatcher --skill maps hermes cron edit --add-skill maps hermes cron edit --remove-skill blogwatcher hermes cron edit --clear-skills ``` Notes: - repeated `--skill` replaces the job's attached skill list - `--add-skill` appends to the existing list without replacing it - `--remove-skill` removes specific attached skills - `--clear-skills` removes all attached skills ## Lifecycle actions Cron jobs now have a fuller lifecycle than just create/remove. ### Chat ```bash /cron list /cron pause /cron resume /cron run /cron remove ``` ### Standalone CLI ```bash hermes cron list hermes cron pause hermes cron resume hermes cron run hermes cron remove hermes cron edit [...flags] hermes cron status hermes cron tick ``` What they do: - `pause` — keep the job but stop scheduling it - `resume` — re-enable the job. A recurring job whose slot came due while it was paused keeps that slot due, so the next tick fires one catch-up run (or logs the skip when `cron.catch_up_missed: false`) instead of silently jumping to the next occurrence; otherwise the next future run is computed - `run` — trigger the job on the next scheduler tick - `remove` — delete it entirely - `edit` — modify schedule, prompt, delivery, etc. **Name-based lookup.** All four mutating verbs (`pause`, `resume`, `run`, `remove`, `edit`) plus the agent's `cronjob_manage` tool now accept a job **name** (case-insensitive) in place of the hex ID. The agent and CLI both prefer an exact ID match if one exists; ambiguous name matches (multiple jobs sharing the same name) are refused with the full list of candidate IDs so you can pick one explicitly. Names are not unique, so this guard is load-bearing — it prevents silently mutating the wrong job when two share a name. ### Pausing everything: `hermes pause` `hermes pause [--reason ...]` is the global emergency stop (`hermes resume` lifts it). While it is engaged no scheduled cron fire starts, whichever door it arrives through: the built-in ticker skips its dispatch, the managed-cron (hosted scheduler) fire webhook answers `503` with `Retry-After: 60` so the scheduler redelivers the fire after you resume, and the [misfire catch-up](#misfire-catch-up) sweep stays idle instead of force-firing everything that was held back. Runs already in flight are never killed, and nothing is lost: due work catches up on the first tick or sweep after `hermes resume`. Explicit manual runs (`hermes cron run`, the dashboard's Trigger button) are an operator override and still execute while paused. ### Creating a job paused (safe canary) Create a canary without a create-then-pause scheduling race: ```bash hermes cron create "every 1h" "Post the digest" --paused --paused-reason "Awaiting review" hermes cron resume ``` `--paused` stores `enabled: false`, `state: paused`, `next_run_at: null`, a pause timestamp and an auditable reason in the first locked write, without registering a trigger. Omit the reason to store "Created paused; awaiting operator approval." Omit `--paused` to retain normal enabled creation. `--paused-reason` requires `--paused`; invalid values are rejected before persistence. The same `paused` boolean and optional `paused_reason` string are accepted by `cron.jobs.create_job`, the cron management tool's `create` action, the gateway `POST /api/jobs`, and the dashboard `POST /api/cron/jobs`. Resume schedules the next future run. Pausing prevents automatic fires, not operator overrides: existing explicit **Run now** / force-run behavior remains available and can resume and run the job. It is not a security boundary against an operator who can run jobs. ## Agent-managed scheduling (cron jobs that manage cron jobs) By default, agents launched *by* the scheduler cannot use the `cronjob_manage` tool — a scheduled job cannot create, edit, or remove other jobs. Opt in via `config.yaml`: ```yaml cron: allow_agent_scheduling: true # default: false ``` When enabled, a scheduled agent can manage the cron table like any chat session: schedule follow-up one-shots from within scheduled work, tune its own cadence, or run a "cron librarian" job that reconciles the whole table (list, then update/remove/create as needed). Two properties keep this sane: - **One flat, user-owned table.** Jobs created from a cron run land in the same `jobs.json` as every other job with no special ownership — you can list, edit, or remove them exactly as if you had created them yourself. - **No dangling delivery.** A cron run is ephemeral, so `deliver: origin` from inside one is resolved **at create time** to the creating job's own concrete target (`platform:chat_id[:thread_id]`, or `local` if the creating job delivers nowhere). A job created by a scheduled agent can never point its output at a session that no longer exists. Explicit targets (`local`, `all`, `telegram:`) are honored verbatim. - **A job may remove itself and still report.** The "watch for X, tell me once, then stop" pattern — a recurring job whose run calls `cronjob(action="remove", job_id=)` and then answers — delivers that final response and records the run as `completed`; the job record and its `cron/output//` directory are gone afterwards and the final run is not written there. Deleting the record from *outside* the run (another process, or a replacement job reusing the id) still discards the stale run's output, as before. Prefer prompts that update existing jobs (list first, then update by ID) over ones that create new jobs each run. ## 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. ```bash 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 ``` `hermes cron status` reports whether the scheduler is alive (gateway process, ticker heartbeat, last successful tick) and the soonest scheduled run across your active jobs, ordered by actual instant even when jobs store different UTC offsets. A `next_run_at` that is already more than 15 minutes in the past is never shown as an upcoming "Next run": `cron status` prints `⚠ Next run