# Scheduler (/docs/api/gateway/schedules)

Scheduled agent tasks, terminal commands, webhooks and chains that run on any of your PCs, phones, watch or cloud VMs — cron or plain words, approvals, retries, failover and live run status.

The codai Scheduler runs work on a timetable across every device you own. The cloud owns each schedule and decides when a run is due; your hosts (the desktop app, `codaid` on servers and VMs, the phone) claim runs, execute them and report back. One list, one history and the same approvals on the hub, desktop, phone and watch.

## What can run [#what-can-run]

- **agent** — a codai agent task (`prompt`, optional `skill`, `permissions` read / edit / pr).
- **shell** — a terminal command on a host (`command`, `cwd`, `env_names`, `shell`). Secret values never leave the host: the schedule only names the variables.
- **http** — an HTTPS webhook (`method`, `url`, `headers`, `body`, `expect_status`).
- **chain** — run other schedules in order (`steps`, `stop_on_failure`).
- **phone_action** — notify, start an agent, open an app, toggle Wi-Fi or Do Not Disturb on one phone.

## When [#when]

`trigger` is a cron expression with a time zone (5 or 6 fields, vixie-cron day semantics), a one-off `at`, an `interval`, a trigger `event`, or `after` another schedule (on success, failure or always). `POST /v1/schedules/preview` turns plain words in English or Romanian ("every weekday at 9", "în fiecare zi la 3") into cron and returns the next five runs plus a daylight-saving warning when a run falls in a clock-change window. Missed runs (host asleep, outage) follow `misfire`: `skip`, `once` (default) or `all_within` the `misfire_window_min`.

## Where [#where]

`target` picks a single host, any host with all the given `tags`, `any` online host, the codai `cloud`, or a `cloud_vm` of a project. A `cloud_vm` run (shell or http) goes to the daemon of one of your codai cloud environments in that project; when none is online, the scheduler starts the most recently used stopped environment (one hour of wallet balance required) and the environment's idle timeout stops it again after the run. `failover` lists tag sets to try when the target is offline. A host claims a run with a 60-second lease (`POST /v1/scheduler/claim`, `FOR UPDATE SKIP LOCKED`, so two hosts never take the same run), keeps it alive with heartbeats and reports a result. An expired lease becomes a new attempt that another host can pick up. With `native: true` and a single-host target the desktop app also mirrors the schedule into the operating system's scheduler (Task Scheduler, launchd or systemd timers) so it fires even when codai is not running.

## Safety [#safety]

- **Shell commands need the host's approval.** A shell schedule stores the sha256 of `shell, cwd, command, env names`. Each host shows you the exact command once and approves that hash (`POST /v1/schedules/{id}/approve-command`); any edit changes the hash and clears every approval.
- **Approvals per run.** `policy.approval` = `always` or `outside_hours` (with `quiet_hours`) puts runs in `awaiting_approval`; you get a notification on every device, including the watch, and approve or skip from any of them.
- **Limits.** `policy.max_runs_per_hour`, budgets per run and per day (micro-USD), `overlap` (`skip`, `queue`, `kill`), `timeout_s` and `retries` with `retry_backoff_s`.
- **Alerts.** Failures (`alert_on_fail`) and runs nobody started within `late_after_s` send a notification with a deep link to the run.

## Pause, skip days and noise control [#pause-skip-days-and-noise-control]

- **Vacation mode.** `POST /v1/schedules/pause` (optionally `until`) stops creating runs for all your schedules; `POST /v1/schedules/resume` ends it, and `backfill: true` gives each schedule that missed slots exactly one catch-up run.
- **Skip days.** `policy.skip_days` skips Romanian public holidays (`holidays: 'RO'`, including Orthodox Easter and Pentecost), chosen weekdays or exact dates, in the trigger's time zone. Skipped slots are never created.
- **Budgets and auto-pause.** When today's runs reach `policy.budget_micro_usd_per_day` new runs are skipped with one alert per day. After `policy.pause_after_failures` consecutive failures (default 3) the schedule disables itself and tells you.
- **Notify.** `notify` = `on_failure` (default), `on_change` (only when the normalised output differs from the last success: timestamps and ids are ignored), `always` or `never`.
- **Templates.** `GET /v1/schedules/templates` lists ready-made schedules such as the weekly transcripts digest (summary, decisions, action items, emailed to you every Friday at 17:00).

## Heartbeat monitors [#heartbeat-monitors]

For jobs codai does not run (a crontab on a VM, a CI job, a Cloud Scheduler job), create a monitor with `POST /v1/heartbeats` `{ name, period_s, grace_s }`. The response contains `ping_url` once. The job calls it:

- `GET|POST /ping/<token>` on success, `/ping/<token>/start` when it starts, `/ping/<token>/fail` on failure, or `/ping/<token>/<exit code>`.

No success within `period_s + grace_s` (or a start without a success within the grace) marks the monitor `down` and alerts every device once; the next success marks it `up` and tells you it recovered.

```bash
# crontab: back up, then report the exit code
0 3 * * * /srv/backup.sh; curl -fsS -m 10 https://ai.codai.ro/ping/<token>/$?
```

<OpenAPIPage document="./openapi/en/gateway.yaml" operations={[{path: '/v1/schedules/pause',method: 'post'},{path: '/v1/schedules/resume',method: 'post'},{path: '/v1/schedules/templates',method: 'get'},{path: '/v1/schedules/templates',method: 'post'},{path: '/v1/heartbeats',method: 'get'},{path: '/v1/heartbeats',method: 'post'},{path: '/v1/heartbeats/{id}',method: 'patch'},{path: '/v1/heartbeats/{id}',method: 'delete'}]} />

## Live status [#live-status]

`GET /v1/schedules/stream` sends a `schedule` event whenever a schedule changes or a run moves state on any device, so every screen updates within a second.

```ts
import { Codai } from 'codai-sdk';

const codai = new Codai({ apiKey: process.env.CODAI_API_KEY! });
const preview = await codai.schedules.preview({ text: 'every weekday at 9', tz: 'Europe/Bucharest' });
const backup = await codai.schedules.create({
  name: 'Nightly backup',
  kind: 'shell',
  spec: { command: 'restic backup ~/work' },
  trigger: { type: 'cron', expr: '0 3 * * *', tz: 'Europe/Bucharest' },
  target: { type: 'tags', tags: ['pc'] },
  retries: 2,
});
for await (const e of codai.schedules.stream()) console.log(e.op, e.state);
```

```python
from codai import Codai

codai = Codai(api_key="codai_...", device="7f0e3c1a-...")
job = codai.schedules.executor.claim()  # host side; None when there is nothing to run
if job:
    codai.schedules.executor.result(job["run"]["id"], {"attempt": job["run"]["attempt"], "ok": True, "exit_code": 0})
```

<OpenAPIPage document="./openapi/en/gateway.yaml" operations={[{path: '/v1/schedules',method: 'get'},{path: '/v1/schedules',method: 'post'},{path: '/v1/schedules/preview',method: 'post'},{path: '/v1/schedules/stream',method: 'get'},{path: '/v1/schedules/{id}',method: 'get'},{path: '/v1/schedules/{id}',method: 'patch'},{path: '/v1/schedules/{id}',method: 'delete'},{path: '/v1/schedules/{id}/run',method: 'post'},{path: '/v1/schedules/{id}/runs',method: 'get'},{path: '/v1/schedules/{id}/approve-command',method: 'post'},{path: '/v1/schedule-runs',method: 'get'},{path: '/v1/schedule-runs/{id}/decision',method: 'post'},{path: '/v1/schedule-runs/{id}/heartbeat',method: 'post'},{path: '/v1/schedule-runs/{id}/result',method: 'post'},{path: '/v1/scheduler/hosts',method: 'get'},{path: '/v1/scheduler/hosts/{id}',method: 'patch'},{path: '/v1/scheduler/claim',method: 'post'}]} />
