Scheduling
config/cron.toml and config/heartbeat.toml let an agent run turns on a schedule.
Cron jobs
config/cron.toml defines a [[jobs]] array.
toml
[[jobs]]
id = "daily-report"
prompt = "Write a status report and send it."
delivery = "announce"
execution = "isolated"
enabled = true
target = "last"
[jobs.schedule]
every = 86400Job fields
| Field | Default | Description |
|---|---|---|
id | — | Stable job identifier (required). |
prompt | — | Job prompt (required). |
delivery | "announce" | announce, webhook, or none; webhook requires isolated execution. |
execution | "isolated" | isolated or main. |
enabled | true | Whether the job runs. |
target | "last" | Target session for announce delivery. |
provider | — | Provider override. |
model | — | Model override. |
webhookUrl | — | Absolute webhook URL; required for webhook delivery. |
schedule | — | Exactly one of every, cron, or at (required). |
Schedules
every— interval in seconds, minimum 60.cron— a five-, six-, or seven-field cron expression.at— an ISO-8601 one-shot timestamp.
Five fields are minute hour day-of-month month day-of-week, with seconds fixed to zero. Six fields add leading second; seven fields also add trailing year. An omitted year matches every year. Fields support *, comma lists, ranges (a-b), and steps (/). Expressions are parsed during dry-run and startup configuration loading.
Delivery and execution
isolated execution runs the prompt in a fresh internal session and delivers the captured response. main execution runs the prompt in the target session; announce sends responses there and none suppresses response delivery.
announce delivers output to the resolved target. webhook is supported only with execution = "isolated": it POSTs { agentSlug, content, jobId } to webhookUrl and treats a 2xx response as success. none runs without delivering.
Configurations combining execution = "main" with delivery = "webhook" are rejected. To retain webhook delivery, change execution to "isolated"; to retain the main session's history, change delivery to "announce" or "none".
The schedule tool creates one-shot isolated jobs dynamically for ordinary sessions. In remote sessions, it creates caller-attested main-session jobs against an owned remote target. Replacing an unstarted reminder with the same ID fences the old worker; remote replacements must belong to the same caller.
One-shot lifecycle and recovery
Local one-shots, including at jobs in cron.toml, are durably claimed before their turn starts. Their persisted state changes from pending to running, then to completed or failed. A turn failure or delivery failure is terminal, not an instruction to execute the prompt again. A process interrupted after claiming leaves running state with an uncertain outcome; restart does not replay it. This is at-most-once attempt admission, not a guarantee that all tool effects or delivery succeeded.
Only pending jobs are recovered, including overdue one-shots. Main-session one-shots whose target is unavailable or busy remain pending and are checked again after one second. Retained session history does not enqueue work. Completed, failed, and uncertain attempts remain in cron_jobs for inspection but are not runnable; remote one-shots instead consume their persisted row before execution.
A dynamic replacement gets a new revision, so a predecessor cannot complete or consume its successor even when both use the same ID. To deliberately retry, inspect the previous attempt's effects first, then create a new dynamic schedule. A configured at job is identified by its ID across reloads and restarts; use a new ID for a new intended execution rather than editing the timestamp of a terminal job. Interval and cron-expression schedules continue to recur.
Containing historical replay
If an installation has already replayed historical continuations, stop the runtime before restarting or upgrading it and back up the complete runtime root. Older local one-shots can still have status = 'pending' even after their turns performed work, especially when response delivery failed. There is no trustworthy attempt marker on those old rows, so the runtime cannot automatically distinguish them from legitimate pending work.
Inspect the affected agent's <root>/agents/<slug>/sessions.db offline:
sql
SELECT job_id, status, next_run, last_run, created_at
FROM cron_jobs
WHERE type = 'one-shot' AND status = 'pending';Correlate IDs with the operator's task records and logs. Quarantine only confirmed stale IDs before starting the runtime, preserving their payload and history:
sql
UPDATE cron_jobs SET status = 'cancelled'
WHERE type = 'one-shot' AND status = 'pending'
AND job_id IN ('confirmed-stale-job-id');Do not cancel every overdue job or infer completion merely from the presence of a session. Keep genuinely pending jobs intact. Review running rows after a crash for possible partial effects before deliberately scheduling replacement work.
Heartbeats
config/heartbeat.toml controls periodic self-checks.
toml
enabled = true
interval = 1800
target = "last"
[visibility]
showOk = false
showAlerts = true
useIndicator = true| Field | Default | Description |
|---|---|---|
enabled | false | Whether heartbeats run. |
interval | 1800 | Interval in seconds. |
target | "last" | Target session. |
provider | — | Provider override. |
model | — | Model override. |
activeHours | — | Optional start/end/timezone window. |
visibility.showOk | false | Show successful heartbeat output. |
visibility.showAlerts | true | Show alert output. |
visibility.useIndicator | true | Accepted setting; currently has no effect. |
A heartbeat reads tasks/HEARTBEAT.md; if the file is absent or empty, the heartbeat is skipped. Responses equal to HEARTBEAT_OK after trimming are delivered only when showOk is enabled; other responses are delivered only when showAlerts is enabled. useIndicator is retained in configuration, but the runner does not currently emit channel indicators.
Conversational check-ins
Use a separate heartbeat session when the heartbeat should decide whether to reach out but the conversation should generate the actual message:
toml
enabled = true
interval = 1800
target = "internal:heartbeat"
[visibility]
showOk = false
showAlerts = falseEnable invoke = true in config/tools.toml. In tasks/HEARTBEAT.md, tell the agent when a check-in is appropriate and ask it to call invoke with the explicit existing DM session ID and the reason for reaching out. The heartbeat should finish with no-response when it has nothing to say in its own session.
The invocation queues a new turn in the DM, using the DM's conversation history and selected model rather than copying the heartbeat's operational transcript or composing a message to forward. Heartbeat visibility settings affect the heartbeat session, not the invoked DM's response. Do not leave the heartbeat target as last for this setup: it can select the DM itself, and invoke rejects invoking the current session.
This separates future heartbeat turns from the DM; it does not remove previous heartbeat content or summaries already present in that conversation, and agent-wide instructions still apply to both sessions.
Reload
Changes to cron.toml and heartbeat.toml are applied live while the runtime is running.