Cron Patterns
The scheduling stack I actually run: systemd timers for OS plumbing, crontab lines that fire reviewed Brigade runbooks, OpenClaw cron for agent tasks, and a small n8n for watchdogs. Pick the wrong layer and you’ll spend 30 minutes a week chasing ghosts.
What this is
Most stacks accumulate scheduled tasks across three or four layers (crontab, systemd timers, in-app schedulers, n8n, etc.) without anyone deciding which goes where. The result: heartbeat checks running in the wrong layer, LLM jobs timing out because they inherited a 60-second cron timeout, and silent failures because nothing routes errors back.
This is how I split mine. Four layers, each picked for one thing it’s good at, with explicit gotchas drawn from real incidents. OpenClaw is my canonical agent-cron orchestrator. If you’re on Hermes Agent or another orchestrator, the same model applies, see “Adapting to other stacks” below.
What is running today (counted 2026-10-17):
| Layer | Count | What lives there |
|---|---|---|
| systemd user timers | 61 | Backups, freshness checks, watchdogs, syncs, nightly gateway restart, dependency and plugin smoke checks |
| crontab | 21 active lines | 11 call brigade runbook run --approved <runbook>.json. The rest are small scripts and file syncs |
| OpenClaw gateway cron | 36 jobs | 21 agent turns and 15 command payloads (several of the commands also run runbooks) |
| n8n schedule triggers | 11 across 14 workflows | Watchdogs and digests that post to chat |
Why this way
Four layers, each picked for what it is good at:
| Layer | Good at | Bad at |
|---|---|---|
| systemd timers | OS-level tasks (backups, mounts, sync), precise timing, unit dependencies, Persistent=true catch-up after a reboot | Anything that needs an LLM call, anything that needs to talk to a UI workflow tool |
| crontab + Brigade runbooks | A reviewed multi-step job with a receipt: the runbook JSON is approved (and optionally pinned), so the crontab line stays one short call | Per-job model choice, delivery routing, anything conversational |
| Agent cron (OpenClaw, Hermes, etc.) | Agent tasks (research, summarize, post), per-job model and thinking level, delivery routing to chat channels, command payloads run by the gateway | Multi-step workflows with branches, anything that benefits from a visual graph |
| n8n schedule trigger | Watchdogs and digests that fan out to several sinks, retries with branching, error handlers | Pure OS plumbing (overkill), single LLM calls (overkill) |
The wrong layer doesn’t just feel awkward, it actively makes the task fragile. A 600-second LLM job in crontab will get killed silently. A restic backup running through n8n adds five points of failure for one restic invocation.
The cost of getting this right once is low. The cost of getting it wrong recurs every week.
Adapting to other stacks
This guide uses OpenClaw as the canonical agent-cron orchestrator because that’s what I run. The layering is orchestrator-agnostic. If you’re on a different stack, swap the agent-cron layer and keep the rest:
| You run | Agent-cron swap |
|---|---|
| OpenClaw | openclaw cron CLI, gateway-owned store (this guide’s default) |
| Hermes Agent | Hermes’ scheduled-task config: same principles (low thinking, explicit delivery, scripts not heredocs) |
| Another orchestrator | Whatever cron-config surface it ships with. The three rules in the agent-cron layer below are general. |
| No agent orchestrator | You don’t have an agent-cron layer. Push agent jobs into n8n with HTTP-to-LLM nodes, or skip them. |
The systemd, crontab, and n8n layers are identical regardless. Brigade runbooks are optional: a plain script in a crontab line works, you just lose the approval gate and the receipt.
Prerequisites
- A Linux host with systemd (any modern distro)
- An agent orchestrator that supports its own cron (this guide assumes OpenClaw, but the pattern generalizes)
- Optional: Brigade for
brigade runbook run - An n8n instance for multi-step workflows (any deployment: Docker, native, hosted)
- Comfort with editing systemd unit files and JSON config
Before / After
Before: One crontab -e with mixed entries: restic backups, intel feed scrapers, Discord posters, NAS sync. When something fails, you find out three days later because the only signal was a missing post.
After: Four layers, each visible in the right place:
systemctl --user list-timersshows OS plumbingcrontab -lshows the runbook one-linersopenclaw cron listshows agent jobs- n8n UI shows multi-step workflows
Failures route to a single error channel via n8n’s failure classifier (see Related).
Implementation
Routing decision tree
Is this task at all OS plumbing (backup, sync, mount, log rotation)?
├─ YES → does it need a receipt or several ordered steps?
│ ├─ YES → Brigade runbook, fired by a systemd timer or a crontab line.
│ └─ NO → systemd timer. Done.
└─ NO → does it need an agent turn (research, summarize, judgment)?
├─ YES → OpenClaw cron with a pinned model and low thinking.
└─ NO → does it have multiple steps, branches, or fan-out to chat sinks?
├─ YES → n8n schedule trigger.
└─ NO → OpenClaw cron `command` payload, or reconsider whether it needs scheduling at all.
Layer 1: systemd timers (OS plumbing)
Use for: backups, file sync, mount checks, log rotation, freshness checks, anything that should run even if your agent stack is down.
Skeleton lives in ../templates/cron/systemd-timer.timer + ../templates/cron/systemd-timer.service. Drop both in ~/.config/systemd/user/, then:
systemctl --user daemon-reload
systemctl --user enable --now my-task.timer
Pick OnCalendar= over OnBootSec= for anything date-relative. It survives reboots without drift.
Use Persistent=true if you need the missed-run-on-boot behavior. Otherwise a reboot at 3:01am skips the 3:00am run silently.
Add RandomizedDelaySec= once you have dozens of timers. Sixty timers that all say :00 pile up on the same minute. A 60-second random delay costs a freshness check nothing. My restic freshness timer is OnCalendar=*-*-* 09:00:00, Persistent=true, RandomizedDelaySec=60s.
Layer 1b: crontab lines that call Brigade runbooks
Most of my crontab is one pattern:
15 3 * * * ~/.local/bin/brigade runbook run --target ~/workspace --approved ~/workspace/.brigade/runbooks/nightly-backup.json >> ~/workspace/logs/nightly-backup.log 2>&1
brigade runbook run executes a reviewed runbook JSON and writes a receipt. From brigade runbook --help and brigade runbook run --help (Brigade 0.28.0):
--approvedis the explicit approval for this execution.--dry-runvalidates the runbook and shows its steps without running them.--resume <run-id>retries from the first failed step of a previous run.brigade runbook plan <file>inspects a runbook without executing it.brigade runbook pinwrites or refreshes optional binary pins, and--allow-pin-mismatchoverrides a missing or changed sha256.brigade runbook closeoutmarks a run reviewed or deferred.
Why not a plain script: when a backup stops working I want the receipt that says which step failed, not a log tail.
Keep a line plain when it is a single idempotent command, like a file sync every 30 minutes. Do not wrap everything.
The same runbooks can also be fired from OpenClaw cron as a command payload (below), which adds chat delivery for the result.
Layer 2: Agent cron (OpenClaw, Hermes, similar)
Use for: scheduled research, daily summaries, posting to chat channels, any task that’s “agent does X, returns text, send it somewhere.”
OpenClaw skeleton lives in ../templates/cron/openclaw-cron-job.json. Add jobs with openclaw cron add. The gateway owns job storage (a SQLite store on 2026.10.x), so do not hand-edit the legacy ~/.openclaw/cron/jobs.json. Current Doctor only preserves old file-store jobs: to import them, run openclaw doctor --fix on 2026.9.7 before upgrading to 2026.10 (see the cron deep dive). Hermes and other orchestrators have their own cron-config surface: the three rules below are general. The file format isn’t.
Three things to get right (orchestrator-agnostic):
-
Pin a model and a low thinking level on every agent job. A high-thinking model burns 10-30s per turn just deliberating. Cron jobs are procedural: search, format, post. Reserve high thinking for the few jobs that do real review, and set it on that job explicitly. Reserve interactive defaults for interactive sessions.
-
Always set explicit delivery routing. A bare
"mode": "announce"with multiple channels enabled will guess wrong. Setchannelandtoexplicitly. This applies to any orchestrator that supports multi-channel delivery: the field name varies, the principle doesn’t. -
For anything multi-step, write a script and call it. Most orchestrators ship a security gate that flags inline shell composition (heredocs,
&&-chains, eval). The gate will request human approval, wait, and time out. Put steps in a.shfile under your scripts dir and reference it asbash /path/to/script.sh, or use acommandpayload.
OpenClaw specifics
- Manage jobs with
openclaw cron add | edit | get | list | show | rm | enable | disable | run | runs | status(patches go through the gateway, no restart needed). - There is no
:cronmodel alias any more. Each job carries its ownpayload.model(for exampleopenai/gpt-6-luna) andpayload.thinking(low,medium,high), set with--modeland--thinkingonopenclaw cron addoredit. Of my 36 jobs, 15 pin Luna explicitly. The rest arecommandpayloads (no model) or inherit the agent default. - Two payload kinds matter.
agentTurnruns an agent with a prompt, optionalmodel,thinking,toolsAllow,lightContext, andtimeoutSeconds.commandrunsargvon the gateway with optionalcwd,env,timeoutSeconds,noOutputTimeoutSeconds, andoutputMaxBytes, with no model at all. Usecommandfor anything that is already a script or a runbook. sessionTargetisisolated(fresh session per run, 31 of my jobs) orcurrent(the job is bound to an existing session and keeps its context, 5 of my jobs). The CLI also acceptsmainandsession:<id>.currentjobs are the morning and health briefs that should know what the day already looked like. Acurrentjob stores the session key it was created in, so create it from the conversation it should live in, and re-create it if that session is retired.wakeModeisnowornext-heartbeat(the--wakeflag). 33 of my jobs usenow. Three usenext-heartbeatand still ran on schedule with last statusokon a host where heartbeats are disabled, so I treatnowas the safe default and do not rely onnext-heartbeatfor timing.- Set
tools.elevated.enabled: trueglobally. There is no job-levelelevatedfield. Per-job restriction istoolsAllowplus ascheduledToolPolicythat the gateway attaches (mine showtrustedoraccount). - A job can carry a
failureAlertblock (after,cooldownMs, optionalchannelandto). Mine useafter: 1with cooldowns of one hour to one day, so a failing job pages once instead of every run.
Hermes notes
If you’re running Hermes Agent instead, the agent-cron layer maps to Hermes’ scheduled-task config (whatever your version exposes, the API is younger and changes between releases). The three rules above still hold. The OpenClaw-specific notes won’t apply verbatim, but the underlying failure modes (heredoc detection, thinking-budget inheritance, silent delivery routing) are universal. Most agent orchestrators land on the same traps.
Layer 3: n8n schedule trigger (multi-step workflows)
Use for: fetch + transform + fan-out, anything with conditional branches, anything that needs a real error workflow. Mine is small: 14 workflows, 11 schedule triggers, almost all watchdogs and digests (backup freshness, config drift, version watch, health dashboards) that post a result to chat.
Skeleton at ../templates/cron/n8n-schedule-trigger.json. Import via the n8n UI.
Pin the cron expression in the trigger node, not the workflow’s triggers field. The latter doesn’t always survive workflow edits.
Use the errorWorkflow setting to route failures. A failure-classifier workflow can bucket errors (code-error, auth-failed, rate-limited, transient) and decide whether to escalate. The classifier itself is a single shared workflow that all production workflows reference.
Idempotent fan-out is your friend. If your workflow posts to four sinks, run them in parallel branches with their own retry logic, not in a chain. One slow sink won’t block the others.
Verification
After routing your scheduled tasks, you should be able to enumerate them all in four commands:
# Layer 1 - systemd timers
systemctl --user list-timers --all --no-legend | wc -l
# Expected: one line per timer (61 on my host)
# Layer 1b - crontab lines that call a runbook
crontab -l | grep -v '^\s*#' | grep -c 'brigade runbook run'
# Expected: a small number; every one should name an --approved runbook file
# Layer 2 - OpenClaw cron: model and thinking live under .payload
openclaw cron list --json | jq -r '.jobs[] | [.name, .schedule.expr // .schedule.kind, .sessionTarget, (.payload.model // "-"), (.payload.thinking // "-")] | @tsv'
# Expected: one row per job; agentTurn jobs show a pinned model such as openai/gpt-6-luna,
# command jobs show "-" for model and thinking
# Layer 2 - every announce job has an explicit target (current-session jobs deliver into their own session)
openclaw cron list --json | jq -r '.jobs[] | select(.delivery.mode == "announce" and .delivery.to == null and .sessionTarget != "current") | .name'
# Expected: nothing. A name listed is a routing-roulette risk.
# Layer 3 - n8n schedule triggers: list schedules in the n8n UI (Workflows, filter Active),
# or through whatever read-only n8n tooling you use. Expect one trigger node per scheduled workflow.
If a scheduled task doesn’t show up in exactly one of these, it’s in the wrong place.
Gotchas
Heredoc obfuscation tripped on cron-composed shell commands. The orchestrator’s security gate ran a separate check from the exec-approval gate and tagged bash <<'EOF' patterns, sent an approval request to a chat channel, and waited 30 minutes for a human to confirm, then timed out. Fix: never compose shell in the cron prompt. Always reference a script file, or use a command payload.
Global high-thinking setting silently killed every cron job. When the main interactive model was set to a high thinking budget globally, cron jobs inherited it. Multi-turn jobs that searched, formatted, and posted burned 10-30s per turn on deliberation and never finished within the 600s timeout. Fix: set thinking on each job. At the time this was a shared :cron model alias with thinking: "low". That alias is retired and the setting now lives on each job’s payload. Reserve high thinking for the two or three jobs that do review work (mine run with 1200-3600s timeouts).
Cron delivery routing without an explicit target picks the wrong channel. With multiple chat channels enabled, a bare "mode": "announce" is a coin flip: it will eventually pick a channel the bot isn’t in and silently drop the post. Fix: always set channel and to explicitly. channel: "last" is the openclaw cron add default, so name the channel yourself on isolated jobs.
sessionTarget: current is bound to a specific session. The job stores the session key (agent, account, and session id) of the conversation it was created in, so it only makes sense for briefs that should continue one thread. Fix: create a current job from the conversation it belongs to, and audit them with openclaw cron list --json | jq '.jobs[] | select(.sessionTarget=="current") | .name' after any session cleanup. Use isolated for everything that does not need that history.
elevated tools agent-level overrides may not work in cron context. A regression in one minor release of the orchestrator caused agent-level tools.elevated.enabled: true to be ignored when the agent ran from cron. The job would fail silently because sudo calls inside its script bounced. Fix: set tools.elevated.enabled: true globally (still how I run it on 2026.10.4) and restrict each job with toolsAllow. Older notes of mine mention a per-job elevated field. The current job schema has none.
n8n CLI execute does not trigger errorWorkflow. When you run n8n execute --id <wf-id> from a script (e.g., for testing), failures do not route to your error workflow. Only auto-runs do. Fix: trust the auto-run path for production. For ad-hoc CLI runs, expect to inspect failures manually.
Orchestrator upgrades regenerate systemd unit files and drop custom directives. An in-place upgrade can silently rewrite ~/.config/systemd/user/<orchestrator>.service, dropping EnvironmentFile= and other custom directives. The gateway then crash-loops on missing env vars. Fix: keep custom directives in drop-ins under <unit>.d/ instead of editing the unit, and promote releases through a script that snapshots first. See Upgrade hygiene.
Templates
templates/cron/systemd-timer.service- service unit skeletontemplates/cron/systemd-timer.timer- timer unit skeletontemplates/cron/openclaw-cron-job.json- OpenClaw cron job entry skeleton (a field checklist: create real jobs withopenclaw cron add, which writes the gateway store)templates/cron/n8n-schedule-trigger.json- n8n workflow stub with schedule trigger + error workflow reference
Related
openclaw-cron-deep-dive.md- OpenClaw job schema, models, delivery, and example schedule in detailn8n-patterns.md- n8n Code node pitfalls, workflow_history gotcha, failure classifier in detailhooks.md- pre/post hooks and sandbox shims that limit what cron jobs can do../infrastructure/upgrade-hygiene.md- the release-worktree promotion flow