Automation Schedule Kinds: Cron, Intervals, and Triggers
Explains the five schedule kinds that control when automation jobs fire, including cron expressions, fixed intervals, stream sources, and condition triggers. Useful for anyone configuring job timing in the automations guide.
Read this when
- Choosing between at, every, cron, on-exit, and stream schedules
- Writing a cron expression or a timezone-aware schedule
- Adding a condition script that gates when a job fires
Every time a job fires, one of five schedule kinds decides the timing: the kinds themselves, cron expression rules, dynamic cadence, and condition watchers. This page is part of the Automations guide.
Schedule types
| Kind | CLI flag | Description |
|---|---|---|
at | --at | A single timestamp (either ISO 8601 or a relative form such as 20m) |
every | --every | A repeating fixed interval (10m, 1h, 1d) |
cron | --cron | A cron expression of 5 or 6 fields, with an optional --tz |
on-exit | --on-exit | Fires a single time when a watched command exits (an event trigger; it outlives turn teardown; optional --on-exit-cwd) |
stream | --stream-command | Fires off batched lines that a supervised long-lived command produces |
Both openclaw automations add and openclaw automations edit <job-id> accept these schedule flags. As an example, openclaw automations edit <job-id> --on-exit "./watch.sh" --on-exit-cwd /srv/app turns an existing job into an exit-triggered schedule.
A timestamp given without a timezone is interpreted as UTC. To read an offset-less --at datetime, or to evaluate a cron expression, in a given IANA timezone, add --tz America/New_York. When a cron expression has no --tz, the Gateway host timezone applies. You cannot combine --tz with --every or --on-exit.
The Run at field in the Control UI follows your browser's local timezone. Any time that a daylight-saving transition skips is rejected, so pick a time on either side of the gap. When a repeated fall-back time is entered fresh, its first occurrence is used. If you edit other fields, the existing one-shot instant stays as it was, offset and timestamp precision included.
Expressions that recur at the top of the hour (minute 0 with a wildcard hour field) get staggered automatically by as much as 5 minutes, which spreads out load spikes. Pass --exact when you need exact timing, or --stagger 30s to set an explicit window (cron schedules only).
To turn staggering off in the Control UI, enable Exact timing. For recurring top-of-hour expressions, disable it and clear Stagger window so the default window of up to 5 minutes returns. With other expressions, clearing a saved window while leaving the expression alone disables staggering, and the panel reopens with Exact timing enabled. If a new schedule has a blank window and its expression changes, default timing is kept.
An on-exit job turns itself off once its payload is queued to run. To watch again, re-enable the job, and this works even while the earlier payload is still finishing. Should the new command exit before that payload completes, its exit waits until the previous run settles, then the job is disabled and the next payload starts. Cleanup still running after a timeout response counts here too. Disabling the watch, or changing it, cancels any pending exit. The watched command and working directory can both be changed when you re-enable.
The Gateway owns the process tree of the watched command, including cleanup after a failed startup. That cleanup has to finish before its payload fires or a replacement command starts. When cleanup cannot be confirmed, the Gateway reports the failure, holds a suspension blocker, and will not rearm that watch within the same process. Look for any command processes still running and stop them, then restart the Gateway. Toggling the job off and on again will not clear cleanup whose state is uncertain.
When an at or on-exit job is switched to an every or cron schedule, its inherited deleteAfterRun setting is cleared. An explicit deleteAfterRun value supplied in the same update wins.
Heartbeat task migration
Running openclaw doctor --fix turns supported heartbeat configuration, system-owned monitor jobs, and structured scratch tasks into regular, editable agentTurn jobs. Doctor handles heartbeat shapes from July 2026 onward. For the migration steps and guidance on older versions, see Heartbeat migration.
Conversions keep job IDs and history, scratch revisions, disabled state, schedule anchors and pending occurrences, and tool authority. Legacy configuration or scratch task blocks are retired by Doctor only once the destination has been verified. Where an import is ambiguous or incomplete, the source is kept and the parts needing attention are reported, so no partially migrated job is started.
Once migration completes, the job is authoritative. Manage it with openclaw automations list, get, edit, and remove, or through the automations agent tool. Config reloads, restarts, and later Doctor runs will not overwrite your edits or recreate a migrated monitor you deleted. At runtime, scratch tasks: blocks are not read as schedules, and HEARTBEAT.md is not read.
Monitoring policies
For periodic checks that ought to honor quiet hours and foreground work, use job-level policies. They take effect through the normal scheduler and session admission, with no separate heartbeat runner involved.
| Field | Behavior |
|---|---|
activeHours: { start, end, timezone? } | Restricts execution to a timezone-aware window. Start is inclusive; end is exclusive. Overnight windows are supported. |
idleOnly: true | Gives foreground work priority without occupying a running slot while waiting. |
payload.skipIfScratchEmpty: true | Skips an agent turn with explicitly empty scratch. Missing scratch still runs. |
Leave these fields out and the ordinary automation defaults apply. A manual force-run ignores cadence, yet active hours, authorization, lifecycle, and delivery restrictions still apply. Side effects from a run already under way are not replayed when foreground work starts.
activeHours.start and end rely on HH:MM; 24:00 is accepted only by end. An empty window results when start and end are equal. timezone takes user (the default, which uses the configured user timezone), local (the Gateway host), or an IANA timezone. Across daylight saving changes the window follows local wall-clock time, regardless of the timezone that sets the cron expression's schedule.
A one-shot at job that comes due while outside its active hours remains pending until the window opens. For recurring every and cron schedules, that occurrence is skipped and the schedule moves on to the next scheduled time.
openclaw automations add \
--name "Project monitor" \
--every 30m \
--session isolated \
--message "Check the job scratch for newly blocked work. Report changes that need attention; otherwise reply NO_REPLY." \
--active-hours-start 08:00 \
--active-hours-end 22:00 \
--active-hours-timezone user \
--idle-only \
--skip-if-scratch-empty \
--no-deliver
These same flags apply to automations edit. When adding a window, provide both endpoints; an edit may change just one field of a window that already exists. To drop the window, use --clear-active-hours; to disable idle priority, use --no-idle-only; to delete that stored policy, use --clear-idle-only.
Be explicit in your instructions: name the sources or the checklist, do not infer old tasks from earlier chats, and report any changes that need attention. A human check-in is its own job, run on a cadence you choose deliberately. See Job scratch and quiet results and Owner delivery.
Stream sources
With a stream schedule, an argv command written by an operator runs under the Gateway, and the job is triggered by lines the command writes to stdout and stderr. Stream schedules are event-driven rather than time-due, and they are available by default.
The agent-facing automations tool requires the creator turn to expose exec for the stream's execution location. Under ask: "always", creating a stream prompts the user to approve that particular persistent watcher. The prompt names the command and execution location, describes how output and delivery behave, and states that the watcher keeps running after the conversation ends and restarts automatically following interruptions and Gateway restarts until it is disabled or deleted. What gets approved is that saved watcher, not other commands or further watchers.
{
"tools": {
"exec": {
"host": "gateway",
"security": "allowlist",
"ask": "always"
}
}
}
Fresh approval is required, when exec asks for it, for source changes (command, cwd, matching, or batching), for setting enabled: true, and for clearing restart exhaustion. No execution approval is requested for disabling or deleting a watcher, for equivalent schedule resaves, or for unrelated payload or delivery edits. Streams owned by an account stay manageable by their creator; streams created by an operator stay under operator management.
When exec asks, the Gateway saves no runnable job unless approval is affirmative. If approval is denied, expires, is canceled, or cannot be delivered, the job stays as it was, whatever askFallback says. An approval request that was declined, expired, or canceled is closed and cannot be approved afterward; if you want another attempt under the existing permissions and approval settings, ask the agent for a new proposal. At save time it rechecks current policy and the exact proposal: newly required approval opens a question, a changed proposal or policy needs a fresh decision, and security: "deny" still refuses execution. Sandbox exec authorizes a stream in a dedicated container under that sandbox's policy, and it cannot authorize a Gateway stream. Node-only exec cannot create streams. Full unattended Gateway exec is still available with no question, and explicit full-permission sessions keep their existing host-policy exemption. Merely changing the approval setting does not prompt again for a watcher that is already approved.
A successful automations add or update carries streamExecutionApproval when that save consumed a native user approval. It reports the approvalId, decision: "allow-once", source: "operator", and a human-readable approval message. This confirms the question was already approved, even when the reply is not visible in chat. The field is absent for unattended saves and for unchanged declarations; it neither grants permission for future agent actions nor reports whether the watcher is still running.
When a native approval ends without permission, automations returns saved: false with an outcome of operator-denied, expired, or cancelled. That is a terminal outcome for the proposal, not invalid parameters. A refused update leaves the existing watcher as it was. The refusal applies to that proposal. Later actions, including actions from the same request, must each satisfy their current permissions and approval settings on their own. Automations saved earlier keep running under their configured lifecycle. A generic operator receipt does not establish that the current viewer approved it.
In Control UI, a stream proposal shows Start watching or Save watcher change next to Cancel. Pending approvals come back when the conversation is reopened.
For the current state of a saved watcher, ask the agent to inspect the known job with automations get. The result includes enabled and state.streamStatus. Assistant prose, an earlier observation, and the enabled flag on their own do not confirm that the source has terminated.
Watcher tool results separate the saved configuration from the state the runtime has observed, such as starting, running, stopped, or error. If a disabled watcher's stop is unconfirmed, that means automatic restart is disabled; it does not mean the source has exited. Chat history keeps the observation made for that operation, not a fresh status check or a permission grant. Deleting a saved record on its own also does not prove physical termination.
To stop a saved watcher, ask the agent to disable it with update and job: { enabled: false }, or open it on the Automations page and turn off its Active switch. The switch then reads Paused, and the saved record remains. Disabling a watcher does not stop separate ordinary background processes; those need their own process IDs and confirmed cleanup.
Conversational requests can disable or delete an authorized automation by its exact ID with no further confirmation. Before applying the change, the owner checks the configuration revision it captured; if the definition has changed, a fresh inspection is required. Where the intended target is unclear, the agent should clarify before acting. The Automations switch stays a single action. Execution approval required for creating or restarting a watcher is unchanged.
Approval covers the saved command and paths for as long as the watcher lives. Across the approval wait, Gateway streams check the identity of the executable, script, and working directory. Across that same wait, sandbox streams recheck the configured image and sandbox policy; guest paths resolve in the image and in approved mounts. Approval does not permanently freeze image tags or the files at those paths. On each sandbox start, the operator-configured image is resolved and its immutable Docker image ID is pinned for that attempt.
Sandboxed streams
An agent with a supported local Docker sandbox can create a stream in a dedicated container. Each start and restart builds a fresh container from the configured image, with the same approved workspace, mounts, user, filesystem access, and network restrictions. The configured setupCommand runs for every fresh container. Commands and working directories refer to guest paths. No host fallback exists.
The stream does not share the agent container's existing writable layer, temporary files, sockets, or running services. Put dependencies in the image or in setupCommand, or keep them in approved mounted paths. Mounted workspace data persists; anything changed elsewhere in a stream container is lost when that attempt ends.
Every stream container gets the configured resource limits separately. Additional streams therefore add containers and can raise total memory, CPU, process, and disk use; they do not share a single resource budget with the agent container.
Sandbox tool policy excludes automations by default. Enable it explicitly together with exec; an existing explicit deny still wins:
{
agents: {
defaults: {
sandbox: { mode: "all", scope: "session", workspaceAccess: "ro" },
},
},
tools: {
allow: ["exec", "automations"],
exec: { host: "sandbox", ask: "always" },
sandbox: { tools: { alsoAllow: ["automations"] } },
},
}
This support needs a supported local Docker Unix socket. Other sandbox backends and remote Docker endpoints keep ordinary exec support but cannot create persistent streams. The approval question identifies the dedicated Docker container behavior and explains the long-running command and the automatic restarts.
Before each start, and while the stream is active, OpenClaw checks current session, operator, exec, and sandbox authority. A revoked grant, removed session, explicit exec denial, or changed sandbox configuration cannot inherit the old approval. Active streams block idle pruning. Explicit sandbox removal or recreation removes the associated stream containers and settles workspace changes before retiring their workspace owner.
After a controller crash or lost connection, a stream can keep running until OpenClaw recovers and cleans it up. No autonomous execution lease exists inside the container. Recovery removes the exact recorded stream container through its original Docker daemon and confirms it is gone before settling the workspace, deleting cleanup records, or starting a replacement. If the Docker daemon is unreachable or has changed, replacement is blocked; restore access to the original daemon so cleanup can finish.
On a Linux or macOS host, reclaiming a previous controller's cleanup work after a reboot needs the same recorded machine identity. If that identity is missing, altered, or unreadable, cleanup stays blocked, and a hostname by itself is not proof that the earlier controller is gone. Only an application-specific hash of the OS machine identifier is kept by OpenClaw.
Stop and drain sandbox streams on the current version before downgrading. Such jobs are quarantined by older versions rather than executed on the Gateway. Once you upgrade again, restore or recreate them explicitly under the current policy and approval, since quarantine does not resume them on its own.
Setting cron.triggers.enabled: false turns off streams along with condition-trigger scripts and script payloads. The process stops when the job is disabled or removed, and Gateway shutdown waits for the process tree to be torn down. Quick failures are restarted using the scheduler's built-in error backoff. If five runs in a row finish in under 60 seconds, the job is left in an error state and the usual failure-alert path is used; to clear the restart cap, re-enable the job by hand.
openclaw automations add \
--name "Build event stream" \
--stream-command '["node","scripts/build-events.mjs"]' \
--stream-mode match \
--stream-match '^(failed|recovered):' \
--stream-batch-ms 250 \
--session isolated \
--message "Investigate these build events."
When the condition is a simple log match, the existing match mode is preferable to writing a trigger program. After preparing the source with finite inspection, create the saved watcher directly, since a separate ordinary background watcher is not needed. The agent tool, for instance, can be given:
{
"action": "add",
"job": {
"name": "Application errors",
"schedule": {
"kind": "stream",
"command": ["tail", "-n", "0", "-F", "/workspace/application.log"],
"mode": "match",
"match": "ERROR"
},
"sessionTarget": "current",
"payload": { "kind": "agentTurn", "message": "Report the matching application errors." }
}
}
Every line is accepted under mode: "line" (the default). With mode: "match", only lines that match the compiled match regex are accepted. A batch is closed after batchMs of quiet (250 ms by default, clamped to 50, 5000) or at maxBatchBytes (16384 by default, clamped to 1024, 65536). When the byte cap is hit, the batch ends with [truncated]. In match mode, complete lines are always evaluated against their full text, even beyond maxBatchBytes (only the delivered batch gets truncated); a line cut off at the bounded raw-intake limit is merely a prefix, so it counts as unmatched instead of letting an end-anchored pattern fire on the cut. The batch is then appended to the system-event text or the agent-turn message. Stream schedules reject command payloads, because the source command and the payload command would create ambiguous process ownership.
Matching runs within a bounded processing budget and queue. Should either limit be hit, the source halts with a recorded error; review the match expression and Gateway load, then re-enable the job manually.
Per job, only one payload fire and one bounded pending batch are kept. Lines that arrive while a payload is running, or before the built-in 30-second trigger interval has passed, are coalesced into that pending batch instead of growing an unbounded queue. Gate drops, payload errors, and not-running dispatches are recorded in streamDroppedBatches by a single serialized owner; bounded merges increment streamCoalescedBatches. Payloads that fail are not retried, since repeating them could duplicate their effects. A logical source identity stays stable across supervised child restarts, yet it rotates when the source is disabled, removed, or replaced, which means queued batches from the retired source cannot fire even after an A-to-B-to-A edit. Once a stop has completed, late callbacks from an old child have no effect. No native WebSocket source exists; bridge one using an argv command such as websocat wss://example.invalid/events.
The Gateway scheduler handles batch deadlines and retries while a job is busy. Each elapsed deadline runs once after sleep. When a source is stopped, its pending deadlines are cancelled and started callbacks are allowed to settle.
If a stream job also carries trigger.script, the gate runs once per closed batch. The current batch is exposed as the deeply frozen trigger.streamBatch string next to trigger.state. After gate state is persisted, fire: false drops that batch. fire: true preserves existing trigger message semantics and then appends the batch to the resulting payload. Alternatively, a stream job may use a script payload with no condition gate; that script gets the batch through the same trigger.streamBatch value. A script payload combined with a condition gate is rejected, since both would claim the persisted trigger.state slot.
Dynamic cadence (pacing)
A recurring job may set pacing.min and/or pacing.max to duration strings like 15m or 4h; at least one bound is mandatory. Pair --pacing-min and --pacing-max with automations add|edit (both bounds are removed by --clear-pacing).
When a recurring job is duplicated, the Control UI keeps its pacing bounds. Switching a paced job or its duplicate to Once strips pacing; any other edit leaves the existing bounds in place.
While running an agent turn, a paced job may invoke the automations tool with action: "next_check" and in: "30m". That proposal is scoped to the job currently running and is measured from successful run completion. OpenClaw clamps it to the configured bounds without warning. After a Gateway restart, a future paced deadline is still the next scheduled check.
With no proposal, pacing leaves the normal schedule as it is. Runs that fail, time out, or are skipped discard the proposal, so the existing retry and error-backoff behavior wins. Forcing a recurring job by hand is out-of-band and keeps its pending natural or paced slot. For condition-triggered jobs, the built-in minimum interval stays a lower bound even when a proposal asks for an earlier check.
/loop chat shortcut
In chat, the owner-only /loop [interval] <prompt> command creates a recurring agent-turn job tied to that conversation. Supply an interval such as 5m for a fixed cadence, or leave it out so the loop paces itself between 1 minute and 1 hour with next_check. Conversation-bound loops are listed with /loop status and removed with /loop stop [name].
Day-of-month and day-of-week use OR logic
croner parses cron expressions. If neither the day-of-month nor the day-of-week field is a wildcard, croner matches when either field matches, not both. That is standard Vixie cron behavior.
# Intended: "9 AM on the 15th, only if it's a Monday"
# Actual: "9 AM on every 15th, AND 9 AM on every Monday"
0 9 15 * 1
As a result, this fires about 5-6 times a month rather than 0-1 times a month. To demand both conditions, apply croner's + day-of-week modifier (0 9 15 * +1), or schedule on one field and guard the other inside your job's prompt or command.
Event triggers (condition watchers)
An event trigger attaches a headless condition script to an every, cron, or stream schedule. Time schedules evaluate it when due; stream schedules evaluate it once per closed batch. Only when the script returns fire: true does the scheduler run the normal payload:
{
schedule: { kind: "every", everyMs: 30000 },
trigger: {
// Fires only when the observed status differs from the last evaluation.
script: "const res = await exec({ command: 'gh pr checks 123 --json state -q \\'.[].state\\' | sort -u' }); const status = String(res?.aggregated ?? '').trim(); json({ fire: status !== trigger.state?.status, message: `PR 123 CI: ${trigger.state?.status ?? 'unknown'} -> ${status}`, state: { status } });",
once: false,
},
payload: { kind: "agentTurn", message: "Investigate the CI status change." },
}
Forced manual runs skip this gate: openclaw automations run <job-id> (forced by default) and the automations tool with action: "run", runMode: "force" bypass trigger.script and run the payload directly. An agentTurn payload wakes the model even when the script would return fire: false. To test the gate, wait for a scheduled evaluation, or use openclaw automations run <job-id> --due when the job is due. The tool's default runMode: "due" also respects the gate; neither due mode makes a job due ahead of time.
openclaw doctor --fix converts persisted trigger scripts that call tools.call('exec', args) and read the .result.details envelope. Custom or ambiguous scripts are left untouched by Doctor, which flags each affected job for manual conversion; standalone script payloads are not converted.
The script has to return { fire, message?, state? }. A deeply frozen trigger.state holds the prior JSON state, and stream gates are additionally passed the current batch as trigger.streamBatch. To persist a new value, return it as state. The state size limit is 16 KB. If a firing result carries message, the scheduler appends that text to the system-event text or the agent-turn message before the run executes. once: true turns the job off once its first fired payload succeeds.
If you modify a running watcher's condition or saved state, that edit is shielded from state updates belonging to the old evaluation and from once completion, including after a Gateway restart. Run history stays attached to the completed payload. A watcher you leave unchanged behaves as usual under once; giving it a new name does not reset its condition.
fire: false saves evaluation state and counters, then reschedules without writing any run history. During restart catch-up, these quiet evaluations still count as completed occurrences. Should a fired payload run fail, the state it returned is not saved: the following evaluation sees the earlier state and may fire again, so write scripts as read-only checks and put actions in the payload. Trigger schedules enforce a built-in minimum interval of 30 seconds, which survives maintenance and Gateway restarts. Every evaluation gets a 30-second wall-clock budget and at most 5 tool calls.
Through the same MCP.<server>.<tool>({ ...input }) namespace that interactive Code Mode uses, scripts can call tools on configured MCP servers. MCP is opt-in per server: only servers named by the job's toolsAllow (--tools) with a server prefix are reachable, either as an exact tool like wispr-flow__search_meetings or as a server-scoped glob like wispr-flow__*. No MCP namespace is given to the script, and no server is started, when the entry is a wildcard *, when toolsAllow is missing, or when a glob lacks a server prefix, since evaluations may run as often as every 30 seconds. Likewise, a script whose source never mentions MCP starts no server, even if toolsAllow names one for the payload. Inside a named server, the owning agent's tool policy still governs access, just as in an interactive run. The same before-tool-call hooks and approvals apply to MCP calls, and results come back with the same untrusted-content handling. For each evaluation, a runtime is started for the named servers only and retired when the evaluation finishes; if a server shuts down slowly, it completes in the background after a 1-second grace. Budget consumption includes connecting and listing tools against the 30-second limit, and every MCP call against the 5-call limit. When a named server fails to start, it is missing from MCP; should the script fail afterwards, the run error ends with MCP server "<name>" is unavailable: <reason>.
For an MCP-backed check, returning fire: false causes quiet ticks to skip the payload along with its model call:
const { structuredContent } = await MCP.wisprFlow.searchMeetings({
since: trigger.state?.cursor,
});
const meetings = structuredContent?.meetings ?? [];
if (meetings.length === 0) return { fire: false };
return {
fire: true,
message: `${meetings.length} new meetings`,
state: { cursor: meetings.at(-1).id },
};
If a job is removed or disabled while its condition is being evaluated, that evaluation is cancelled before any payload can start. Payloads targeting the main session go through ordinary session admission and keep the job's cancellation and authority checks.
Design watchers around actionable state, not success alone: a watcher that falls silent when its check fails or times out appears healthy while it is actually broken. Deduplicate by comparing the observation against trigger.state and returning fresh state; model or process memory is not a reliable substitute. Make message self-contained when firing, since it becomes the complete event context of the fired run.
Warning
By default, condition-trigger scripts and
scriptpayloads run unattended with the owning agent's full tool policy, includingexec. Operator-authored commands in stream schedules also keep running unattended. Treat all of these surfaces as unattended code execution carrying that agent's permissions. For a hard stop, operators can setcron.triggers.enabled: false; re-enable them by removing it or setting it totrue.
To create a watcher from a local script file, use the CLI below (- reads the script from stdin). File paths keep their leading and trailing spaces, so quote the path as a single shell argument:
openclaw automations add \
--name "PR CI watcher" \
--every 30s \
--trigger-script ./watch-pr-ci.js \
--message "Respond to the CI status change" \
--session isolated