> ## Documentation Index
> Fetch the complete documentation index at: https://docs.plungeai.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scheduling — cron jobs for workflows, agents, and missions

> The scheduler runs platform work unattended on cron expressions: a saved workflow every morning, an agent query hourly, a pre-built agent card nightly.


<!-- sources-of-truth: orchestration/scheduler/README.md, orchestration/scheduler/CLAUDE.md, orchestration/scheduler/SchedulerDO.ts (heartbeat delivery), orchestration/scheduler/delivery.ts, orchestration/mcp-gateway/server.ts, orchestration/mcp-gateway/tools.ts, orchestration/mcp-gateway/extras.ts, orchestration/cnl-engine/README.md (date tokens) | last-synced: 2026-09-24 -->
The scheduler runs platform work unattended on cron expressions: a saved workflow
every morning, an agent query hourly, a pre-built agent card nightly. Jobs, run
history, retries, and stats are first-class — a scheduled job is not fire-and-forget;
every run is logged and resolvable like any other execution.

## Creating and managing jobs — `plungeai_schedule`

One MCP tool, action-routed:

```
plungeai_schedule {action: "create",
  name: "Daily market brief",
  job_type: "workflow",            # agent | query | workflow | heartbeat
  target: "<workflow id>",
  schedule: "0 7 * * *"            # 07:00 UTC daily
}

plungeai_schedule {action: "list"}
plungeai_schedule {action: "get", job_id: "…"}
plungeai_schedule {action: "update", job_id: "…", schedule: "0 8 * * 1-5"}
plungeai_schedule {action: "pause",  job_id: "…"}
plungeai_schedule {action: "resume", job_id: "…"}
plungeai_schedule {action: "run_now", job_id: "…"}   # returns the run's REAL outcome + execution id
plungeai_schedule {action: "runs", job_id: "…"}      # execution history
plungeai_schedule {action: "stats"}
plungeai_schedule {action: "delete", job_id: "…"}
```

Also on create: `mission_ref` schedules a pre-built agent card directly (a bounded
mission on cron — see `plungeai-missions`), and `parameters` carries the run input.

### Heartbeat jobs — watch a condition, act only when it trips

`job_type: "heartbeat"` is a user-facing job kind (not just internal plumbing): each
tick it runs `check_agent` against `condition_prompt` and only acts when the
condition is met. Create requires `name`, `schedule`, `check_agent`,
`condition_prompt`; optional actions: `trigger_workflow` (a workflow id to fire) and
`notify_channel` (`telegram | whatsapp | discord | slack | email`) + `notify_chat_id`.
Heartbeat notify routes through the same per-channel delivery adapters as ordinary
scheduled-result delivery — telegram/discord/slack/email all deliver (subject to
each channel's normal pairing/confirmation fence, see `plungeai-campaigns`'
delivery-channel table); **whatsapp has a known gap** — the heartbeat payload never
carries the `phone_number_id` the whatsapp adapter requires, so a whatsapp target
silently no-ops today.

```
plungeai_schedule {action: "create", job_type: "heartbeat",
  name: "Price watch", schedule: "*/30 * * * *",
  check_agent: "<agent id from a live search>",
  condition_prompt: "Is BTC below $50k? Answer MET or NOT MET with evidence.",
  notify_channel: "telegram", notify_chat_id: "…"}
```

Heartbeat condition checks run memory-free — they are watch-and-notify ticks, not
learning runs.

Semantics you should rely on:

- **Workflow jobs run by reference** — the scheduler stores a pointer, not a YAML
  copy. Edit the workflow in Studio and the next scheduled run uses the latest
  version automatically. Never "update the schedule" to change workflow logic.
- **Agent and query jobs are auto-wrapped at create time** into a saved one-task
  workflow (`Scheduled: <name>`) and scheduled as workflow jobs — so their runs get
  full standard execution history too.
- **Delete is soft**: history stays readable; a deleted job refuses
  run_now/update/pause/resume. There is no undelete — create a new job.
- **`run_now` returns the real outcome** plus a resolvable execution id — use it to
  verify a job end-to-end right after creating it (always do this).

## Cron expressions

Standard 5-field cron, minimum granularity **1 minute**, evaluated in UTC:

```
minute hour day month day-of-week
```

| Intent | Expression |
|---|---|
| Every 5 minutes | `*/5 * * * *` |
| Hourly on the hour | `0 * * * *` |
| Daily 07:00 | `0 7 * * *` |
| Weekdays 09:00 | `0 9 * * 1-5` |
| Mondays 09:00 | `0 9 * * 1` |
| 1st of month 00:00 | `0 0 1 * *` |

`,` lists, `-` ranges, `/` steps, `*` any. Cron accuracy is ±5 seconds — do not
build designs that need tighter timing.

### `@once` and `runAgain` — one-time and self-scheduling jobs

`@once` is a valid schedule that fires exactly once, at the job's explicit next-run
time, then soft-deletes itself (run history survives). A failed `@once` run may
retry once with the standard +5 min delay before its final deletion.

`runAgain` is the mechanic behind a **campaign** that re-schedules itself without a
cron expression: the last row of its per-cycle pipeline is `agent: scheduler,
params: {operation: "runAgain", in: "1h"}`. It creates exactly ONE `@once` job for
that workflow (never stacks — an existing pending `@once` job for the same workflow
is re-timed, not duplicated). `in` accepts a duration (`5m`, `1h`, `90s`) or a
number of seconds, default `1h`. This is orthogonal to a campaign scheduled *with* a
cron expression, which is driven by an ordinary recurring job instead — see
`plungeai-campaigns`.

## Date tokens — schedules that reason about time windows

Scheduled workflows should never bake concrete dates into YAML. The engine seeds
rolling tokens from each run's execution time — same `{placeholder}` syntax as
inputs, lower precedence than caller inputs:

`{now}` `{today}` `{yesterday}` `{week_start}` `{week_end}` `{last_week_start}`
`{last_week_end}` `{month_start}` `{month_end}` `{last_month_start}`
`{last_month_end}` — all UTC, ISO weeks (Monday = day 1).

```yaml
- type: task
  id: compare
  agent: llm-agent
  prompt: |
    Compare this week ({week_start} to {week_end}) against last week
    ({last_week_start} to {last_week_end}). Identify the notable moves.
```

The scheduler passes the execution time automatically — a job scheduled "Monday
09:00" reasons about the correct ISO week every time, forever, with zero YAML edits.

## Runs, retries, and history

- Every run is logged: status (`running` → `completed`/`failed`), start/end,
  duration, result pointer, error message, attempt number, what triggered it
  (schedule vs manual).
- **Failed runs auto-retry**: up to 3 attempts by default, 5 minutes apart; after
  max retries the job returns to its normal schedule (it is not paused by failure).
  The per-job `max_retries` override is settable only on the REST/Studio surface
  (`POST /jobs`) — `plungeai_schedule` has no such field.
- Retry re-fires the full job — schedule only idempotent work, or make the workflow
  itself idempotent (e.g. keyed upserts), for anything with side effects.
- `action: "runs"` lists history; its **Execution ID column** is the id to fetch
  output with `plungeai_get_result` / inspect with `plungeai_get_workflow_status`.
  A scheduler *run id* itself is NOT resolvable by those tools — both will tell you
  to go through `runs` first; do that instead of retrying.
- `action: "stats"` summarizes: total/active/paused jobs, today's runs and
  success/failure split, average duration — the first read when "schedules seem
  broken".

## Memory semantics of scheduled runs

A scheduled user workflow keeps the FULL per-user memory lifecycle — it recalls and
writes long-term memory exactly like a manual run (owner decision; see
`plungeai-memory`). Only system jobs and heartbeat condition checks run
memory-free. Design accordingly: a daily mission genuinely accumulates knowledge run
over run.

## Patterns

- **Morning brief:** workflow (parallel news/search fan-out → synthesis) on
  `0 7 * * *`, date tokens for "since yesterday".
- **Weekly comparison report:** workflow with `{last_week_*}` vs `{week_*}` windows
  on `0 9 * * 1`.
- **Standing watchdog:** `quick`-effort mission card via `mission_ref` hourly; it
  checks a condition and only escalates (writes/notifies) when triggered.
- **Data hygiene:** batch workflow nightly over a table; opt in with `track: true`
  on the batch so re-runs resume instead of repeat (`plungeai-workflows`).
- **Campaign cadence:** a list-to-completion campaign is driven either by an
  ordinary recurring cron job or by its own `runAgain` self-scheduling — see
  `plungeai-campaigns`.

## Troubleshooting

1. Job not firing → `get` the job: `status` must be `active` and next run in the
   future; then `runs` for the last attempt's error.
2. Run failed → `runs` → take the Execution ID → `plungeai_get_workflow_status`
   (which also self-heals stuck runs) and the trace (`plungeai-results-traces`).
3. Wrong data window → check UTC: `0 7 * * *` is 07:00 UTC, not local; date tokens
   are UTC/ISO too.
4. Output looks stale → the job runs the workflow by reference; confirm which
   version is saved (someone may have edited it — that IS the version that runs).

---

## MCP tool: `plungeai_schedule`

**Purpose:** cron-scheduled jobs — run agents, workflows, or condition-check
heartbeats on a schedule. Jobs are ownership-scoped.

**Actions**

| Action | Requires | Notes |
|---|---|---|
| `stats` | — | Job counts by status. |
| `list` | — | Table: Name, Type, Schedule, Status, Next run, ID. |
| `get` | `job_id` | Full job JSON. |
| `create` | see below | Four job shapes (below). |
| `update` | `job_id` (+ any of name/description/schedule/target/parameters) | Deleted jobs refuse it. |
| `pause` / `resume` | `job_id` | Toggle `paused`/`active`. |
| `delete` | `job_id` | SOFT delete — run history stays readable via `runs`; deleted jobs refuse run_now/update/pause/resume. |
| `run_now` | `job_id` | Fire immediately; reports the run's REAL outcome and a resolvable execution id. |
| `runs` | optional `job_id` | Run history table; its **Execution ID** column is what `plungeai_get_result` accepts. |

**Create — the four shapes**

1. **Workflow job:** `job_type: "workflow"`, `target: "<workflow_id you own>"`,
   `name`, `schedule` (cron). The workflow must exist in the caller's account.
2. **Agent job:** `job_type: "agent"`, `target: "<agent-id>"`, `parameters:
   {"prompt": "what to do each run"}`, `name`, `schedule`. The agent is
   fence-checked NOW (unknown/inactive refused at create, not at fire time),
   and the job is auto-wrapped into a visible saved workflow ("Scheduled:
   &lt;name>") so runs land in normal execution history.
3. **Query job:** `job_type: "query"`, `target` is JSON:
   `{"agent": "<agent-id>", "query": "<what to run>"}` — same wrapping and
   fence as agent jobs.
4. **Heartbeat:** `job_type: "heartbeat"`, plus `check_agent`,
   `condition_prompt` (required), optional `trigger_workflow` (workflow id to
   fire when the condition is met), `notify_channel`
   (`telegram|whatsapp|discord|slack|email`) + `notify_chat_id`. Each tick it
   checks the condition and only acts when met. Delivery runs through the same
   per-channel adapters as regular scheduled-result delivery — telegram,
   discord, slack, and email all deliver; ⚠️ **whatsapp is a known gap** (the
   heartbeat payload doesn't carry the `phone_number_id` the adapter requires,
   so it silently no-ops) — prefer `trigger_workflow` or another channel for a
   whatsapp-bound notice.

Also on create: `mission_ref: "<agent-card-id>"` (+ `name`, `schedule`,
optional `parameters.prompt`) schedules a pre-built agent card — the card
resolves LIVE at fire time, so card edits apply to future runs automatically.

**Example**

```json
{"user_request": "every weekday at 7am send me an AI news brief",
 "action": "create", "name": "Morning AI brief", "job_type": "agent",
 "target": "brave-agent", "schedule": "0 7 * * 1-5",
 "parameters": {"prompt": "Top AI news of the last 24h, with links"}}
```

**Failures & fixes**

- "create requires: name, job_type (agent|query|workflow), target, schedule"
  → supply them (heartbeat/mission_ref list their own requireds).
- Fence refusal on `target`/`check_agent` → re-discover with
  `plungeai_list_agents`; never retry the same id.
- "Agent schedules need a prompt" → add `parameters: {"prompt": ...}`.
- "No workflow `<id>` found in your account" → pass an id from
  `plungeai_list_workflows` or build one first.
- "That looks like an execution id (`exec_...`), not a job id (`job_...`)"
  → execution ids identify single runs; use `action: "runs"` to list them,
  or pass the job id.
- "The scheduler's storage is briefly busy... Retry in a few seconds." →
  transient; the job most likely still exists — do NOT treat as not-found.
- `Scheduler error (HTTP <status>).` (e.g. `Scheduler error (HTTP 401).`) →
  the gateway→scheduler hop itself failed; not your arguments. Report it
  rather than retry-looping.
- To fetch a scheduled run's output: `action: "runs"` → take the
  **Execution ID** column → `plungeai_get_result {workflow_id: <that id>}`.
