<!-- SPDX-License-Identifier: Apache-2.0 -->

# 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 = 86400
```

### Job 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](../operations/remote-sessions.md), 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 = false
```

Enable `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`](../reference/tools.md#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.
