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

# Scheduled Tasks (Cron)

> Schedule automated tasks with natural language, manage them with one cron tool, and attach one or more skills

<Info>
  Commands, package names, and image names on this page come from the open-source project that Mibyan Desktop is built on, and can differ from the Mibyan Desktop installer. For the supported Mibyan install and update path, see [Install and update](/products/desktop-guide/install-and-update).
</Info>

Schedule tasks to run automatically with natural language or cron expressions. Mibyan exposes cron management through a single `cronjob_manage` tool with action-style operations instead of separate schedule/list/remove tools.

## What cron can do now

Cron jobs can:

* schedule one-shot or recurring tasks
* pause, resume, edit, trigger, and remove jobs
* attach zero, one, or multiple skills to a job
* deliver results back to the origin chat, local files, or configured platform targets
* run in fresh agent sessions with the normal static tool list
* run in **no-agent mode** — a script on a schedule, its stdout delivered verbatim, zero LLM involvement (see the [no-agent mode](#no-agent-mode-script-only-jobs) section below)
* fire on **external events** — a webhook route with `cron_job` set fires the job the moment something happens (a PR gets feedback, a service posts an alert) instead of waiting for the next scheduled tick. See [Event-Triggered Cron Jobs](/desktop/user-guide/messaging/webhooks#event-triggered-cron-jobs).

All of this is available to Mibyan itself through the `cronjob_manage` tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required.

<Tip>
  **Which model does a cron job run on?** Resolution at fire time is: per-job pin → `cron.model` in `config.yaml` → the main agent model from `mibyan model`.

  * **Per-job pin** — a job that carries its own model. Set it to a specific model via the dashboard, `mibyan cron create/edit --model … --provider …`, or by editing `~/.mibyan/cron/jobs.json`; or **lock in the current main model** with `mibyan cron create/edit --pin` (the agent's `cronjob_manage` tool can do this too with `pinned=true`, but only when you ask it to). `--unpin` (`pinned=false`) releases the lock. The agent cannot point a job at a *different* model — inference pins are user-owned.
  * **`cron.model` / `cron.model_provider`** — a cron-fleet default: every unpinned job runs on this model, independent of your chat model. Set it once (`mibyan config set cron.model <name>`) and switching your chat model with `mibyan model` or `/model` never touches your cron fleet.
  * **Main agent model** — when neither of the above is set, a job runs on whatever `mibyan model` / `/model` is set to **at the moment it fires**. Change your main model and every unpinned job follows on its next run.

  Whichever provider a job resolves to, its provider-specific request settings (e.g. `request_overrides` such as `extra_body`/`extra_headers` for custom providers) carry into the scheduled run just like an interactive session.

  `mibyan setup --portal` is the lowest-friction option for unattended runs since OAuth refresh is automatic. See Nous Portal.
</Tip>

<Tip>
  **Per-job reasoning effort.** A job can pin its own thinking level, independent of the model pin: one of `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, `ultra`. When set, it overrides both the global `agent.reasoning_effort` and per-model `agent.reasoning_overrides` for that job's runs (`none` disables thinking). Set it via `mibyan cron create/edit --reasoning-effort high`; pass an empty string on edit to clear the pin and follow config again. (It is deliberately not exposed on the agent's `cronjob_manage` tool — model configuration stays a user decision.) Levels a model doesn't support are clamped or omitted by the provider at request time — pinning `xhigh` on a model that caps at `high` runs at `high`. The pin has no effect on `no_agent` jobs (there is no LLM call to tune). Use it to run heavy scheduled analyses at `high` while cheap recurring jobs run at `minimal`, without touching your global default.
</Tip>

<Warning>
  Cron-run sessions cannot recursively create more cron jobs. Mibyan disables cron management tools inside cron executions to prevent runaway scheduling loops.
</Warning>

## Creating scheduled tasks

### In chat with `/cron`

```bash theme={null}
/cron add "in 30m" "Remind me to check the build"
/cron add "every 2h" "Check server status"
/cron add "every 1h" "Summarize new feed items" --skill blogwatcher
/cron add "every 1h" "Use both skills and combine the result" --skill blogwatcher --skill maps
```

### From the standalone CLI

```bash theme={null}
mibyan cron create "every 2h" "Check server status"
mibyan cron create "every 1h" "Summarize new feed items" --skill blogwatcher
mibyan cron create "every 1h" "Use both skills and combine the result" \
  --skill blogwatcher \
  --skill maps \
  --name "Skill combo"
```

### Through natural conversation

Ask Mibyan normally:

```text theme={null}
Every morning at 9am, check Hacker News for AI news and send me a summary on Telegram.
```

Mibyan will use the unified `cronjob_manage` tool internally.

## Pre-dispatch configuration validation

Before constructing any agent machinery for a scheduled run, the scheduler
validates that the job's configuration can actually produce a successful run:

* the provider API key resolves (skipped for an unpinned job when a
  `fallback_providers` chain is configured, since the fallback path may rescue a
  missing primary key; a pinned job does not use that chain, so it is always
  checked),
* attached skills are ready (no missing required environment variables,
  commands, or credential files),
* delivery platform targets are known and have gateway credentials configured
  (`local`/`origin` targets are never checked),
* every MCP server the job names in its own `enabled_toolsets` resolved to at
  least one tool for this profile. A server that connected earlier in this
  gateway and is only reconnecting after a network blip (router reboot, DNS
  failure) does **not** block: the job runs with the tools that did resolve and
  the gateway log notes which servers were skipped (once per outage). A server
  that never connected for this profile (wrong URL or credentials, or a server
  another profile owns under a multiplexer), or one parked on a permanent error
  such as revoked credentials, blocks the run.

When validation fails, the job's `last_status` becomes `blocked_config`, ONE
alert is delivered (it is not repeated every tick), and **no LLM call is
made** — a misconfigured job never spends tokens. The next healthy run clears
the blocked state so a future configuration break alerts again.

A missing-credential verdict names the profile and `mibyan_HOME` the scheduler
read, e.g. `provider credential missing: No Codex credentials stored … [profile
'default', mibyan_HOME /opt/data]`. When an interactive session with "the same"
credential works, compare that path with the shell's `mibyan_HOME`: a gateway
started without the shell's environment (Docker `HOME` vs `mibyan_HOME`, a
service unit) or a multiplexed satellite profile reads a different `auth.json`
and `.env` than the shell does.

To disable the validation and restore the old behavior (the run proceeds and
fails during execution):

```yaml theme={null}
cron:
  preflight: false
```

Or: `mibyan config set cron.preflight false`

## Moving unpinned jobs to a new global default

An unpinned job follows the main agent model, so `mibyan model` moves your cron fleet with it.
When you want a job to *stay* on a model:

```bash theme={null}
mibyan cron edit <job_id> --pin                                   # lock the current main model onto one job
mibyan cron edit <job_id> --provider <provider> --model <model>   # pin an explicit model
mibyan cron edit <job_id> --unpin                                 # follow the main model again
mibyan config set cron.model <model>                              # every unpinned job, without touching chat
```

`mibyan cron list` and the `cronjob_manage` tool report `pinned` per job.

## Skill-backed cron jobs

A cron job can load one or more skills before it runs the prompt. Each skill loads exactly as it does from `/skill-name` in a chat session, including the `[Skill config ...]` block with its resolved `metadata.mibyan.config` values from `config.yaml`.

### Single skill

```python theme={null}
cronjob(
    action="create",
    skill="blogwatcher",
    prompt="Check the configured feeds and summarize anything new.",
    schedule="0 9 * * *",
    name="Morning feeds",
)
```

### Multiple skills

Skills are loaded in order. The prompt becomes the task instruction layered on top of those skills.

```python theme={null}
cronjob(
    action="create",
    skills=["blogwatcher", "maps"],
    prompt="Look for new local events and interesting nearby places, then combine them into one short brief.",
    schedule="every 6h",
    name="Local brief",
)
```

This is useful when you want a scheduled agent to inherit reusable workflows without stuffing the full skill text into the cron prompt itself.

## Running a job inside a project directory

Cron jobs default to running detached from any repo — no `AGENTS.md`, `CLAUDE.md`, or `.cursorrules` is loaded, and the terminal / file / code-exec tools run from whatever working directory the gateway started in. Pass `--workdir` (CLI) or `workdir=` (tool call) to change that:

```bash theme={null}
# Standalone CLI (schedule and prompt are positional)
mibyan cron create "every 1d at 09:00" \
  "Audit open PRs, summarize CI health, and post to #eng" \
  --workdir /home/me/projects/acme
```

```python theme={null}
# From a chat, via the cronjob tool
cronjob(
    action="create",
    schedule="every 1d at 09:00",
    workdir="/home/me/projects/acme",
    prompt="Audit open PRs, summarize CI health, and post to #eng",
)
```

When `workdir` is set:

* `AGENTS.md`, `CLAUDE.md`, and `.cursorrules` from that directory are injected into the system prompt (same discovery order as the interactive CLI)
* `terminal`, `read_file`, `write_file`, `patch`, `search_files`, and `execute_code` all use that directory as their working directory
* The path must be an absolute directory that exists — relative paths and missing directories are rejected at create / update time
* Pass `--workdir ""` (or `workdir=""` via the tool) on edit to clear it and restore the old behaviour

<Note>
  **Isolation**

  Each agent run binds its `workdir` to that run's unique task identity. Workdir jobs therefore use the normal parallel pool without mutating process-global terminal state or leaking paths between concurrent runs. Set `cron.max_parallel_jobs` if you want to limit total cron concurrency.
</Note>

## Editing jobs

You do not need to delete and recreate jobs just to change them.

<Tip>
  **Job reference**

  The `<job_id>` placeholder below (and in [Lifecycle actions](#lifecycle-actions)) also accepts the job's name (case-insensitive) — handy when you remember `morning-digest` but not the hex ID. An exact job ID takes precedence over name matches; if the reference is not an ID and a name matches more than one job, the command refuses and prints the candidate IDs so you can disambiguate.
</Tip>

### Chat

```bash theme={null}
/cron edit <job_id> --schedule "every 4h"
/cron edit <job_id> --prompt "Use the revised task"
/cron edit <job_id> --skill blogwatcher --skill maps
/cron edit <job_id> --remove-skill blogwatcher
/cron edit <job_id> --clear-skills
```

### Standalone CLI

```bash theme={null}
mibyan cron edit <job_id> --schedule "every 4h"
mibyan cron edit <job_id> --prompt "Use the revised task"
mibyan cron edit <job_id> --skill blogwatcher --skill maps
mibyan cron edit <job_id> --add-skill maps
mibyan cron edit <job_id> --remove-skill blogwatcher
mibyan cron edit <job_id> --clear-skills
```

Notes:

* repeated `--skill` replaces the job's attached skill list
* `--add-skill` appends to the existing list without replacing it
* `--remove-skill` removes specific attached skills
* `--clear-skills` removes all attached skills

## Lifecycle actions

Cron jobs now have a fuller lifecycle than just create/remove.

### Chat

```bash theme={null}
/cron list
/cron pause <job_id>
/cron resume <job_id>
/cron run <job_id>
/cron remove <job_id>
```

### Standalone CLI

```bash theme={null}
mibyan cron list
mibyan cron pause <job_id_or_name>
mibyan cron resume <job_id_or_name>
mibyan cron run <job_id_or_name>
mibyan cron remove <job_id_or_name>
mibyan cron edit <job_id_or_name> [...flags]
mibyan cron status
mibyan cron tick
```

What they do:

* `pause` — keep the job but stop scheduling it
* `resume` — re-enable the job. A recurring job whose slot came due while it was paused keeps that slot due, so the next tick fires one catch-up run (or logs the skip when `cron.catch_up_missed: false`) instead of silently jumping to the next occurrence; otherwise the next future run is computed
* `run` — trigger the job on the next scheduler tick
* `remove` — delete it entirely
* `edit` — modify schedule, prompt, delivery, etc.

**Name-based lookup.** All four mutating verbs (`pause`, `resume`, `run`, `remove`, `edit`) plus the agent's `cronjob_manage` tool now accept a job **name** (case-insensitive) in place of the hex ID. The agent and CLI both prefer an exact ID match if one exists; ambiguous name matches (multiple jobs sharing the same name) are refused with the full list of candidate IDs so you can pick one explicitly. Names are not unique, so this guard is load-bearing — it prevents silently mutating the wrong job when two share a name.

### Pausing everything: `mibyan pause`

`mibyan pause [--reason ...]` is the global emergency stop (`mibyan resume` lifts it). While it is engaged no scheduled cron fire starts, whichever door it arrives through: the built-in ticker skips its dispatch, the managed-cron (hosted scheduler) fire webhook answers `503` with `Retry-After: 60` so the scheduler redelivers the fire after you resume, and the [misfire catch-up](#misfire-catch-up) sweep stays idle instead of force-firing everything that was held back. Runs already in flight are never killed, and nothing is lost: due work catches up on the first tick or sweep after `mibyan resume`. Explicit manual runs (`mibyan cron run`, the dashboard's Trigger button) are an operator override and still execute while paused.

### Creating a job paused (safe canary)

Create a canary without a create-then-pause scheduling race:

```bash theme={null}
mibyan cron create "every 1h" "Post the digest" --paused --paused-reason "Awaiting review"
mibyan cron resume <job_id>
```

`--paused` stores `enabled: false`, `state: paused`, `next_run_at: null`, a pause
timestamp and an auditable reason in the first locked write, without registering a
trigger. Omit the reason to store "Created paused; awaiting operator approval."
Omit `--paused` to retain normal enabled creation. `--paused-reason` requires
`--paused`; invalid values are rejected before persistence.

The same `paused` boolean and optional `paused_reason` string are accepted by
`cron.jobs.create_job`, the cron management tool's `create` action, the gateway
`POST /api/jobs`, and the dashboard `POST /api/cron/jobs`. Resume schedules the next
future run. Pausing prevents automatic fires, not operator overrides: existing
explicit **Run now** / force-run behavior remains available and can resume and run
the job. It is not a security boundary against an operator who can run jobs.

## Agent-managed scheduling (cron jobs that manage cron jobs)

By default, agents launched *by* the scheduler cannot use the `cronjob_manage` tool —
a scheduled job cannot create, edit, or remove other jobs. Opt in via
`config.yaml`:

```yaml theme={null}
cron:
  allow_agent_scheduling: true   # default: false
```

When enabled, a scheduled agent can manage the cron table like any chat
session: schedule follow-up one-shots from within scheduled work, tune its own
cadence, or run a "cron librarian" job that reconciles the whole table
(list, then update/remove/create as needed). Two properties keep this sane:

* **One flat, user-owned table.** Jobs created from a cron run land in the
  same `jobs.json` as every other job with no special ownership — you can
  list, edit, or remove them exactly as if you had created them yourself.
* **No dangling delivery.** A cron run is ephemeral, so `deliver: origin`
  from inside one is resolved **at create time** to the creating job's own
  concrete target (`platform:chat_id[:thread_id]`, or `local` if the creating
  job delivers nowhere). A job created by a scheduled agent can never point
  its output at a session that no longer exists. Explicit targets
  (`local`, `all`, `telegram:<chat_id>`) are honored verbatim.
* **A job may remove itself and still report.** The "watch for X, tell me
  once, then stop" pattern — a recurring job whose run calls
  `cronjob(action="remove", job_id=<its own id>)` and then answers — delivers
  that final response and records the run as `completed`; the job record and
  its `cron/output/<job_id>/` directory are gone afterwards and the final run
  is not written there. Deleting the record from *outside* the run (another
  process, or a replacement job reusing the id) still discards the stale
  run's output, as before.

Prefer prompts that update existing jobs (list first, then update by ID)
over ones that create new jobs each run.

## How it works

**Cron execution is handled by the gateway daemon.** The gateway ticks the scheduler every 60 seconds, running any due jobs in isolated agent sessions.

```bash theme={null}
mibyan gateway install     # Install as a user service
sudo mibyan gateway install --system   # Linux: boot-time system service for servers
mibyan gateway             # Or run in foreground

mibyan cron list
mibyan cron status
```

`mibyan cron status` reports whether the scheduler is alive (gateway process, ticker heartbeat, last successful tick) and the soonest scheduled run across your active jobs, ordered by actual instant even when jobs store different UTC offsets. A `next_run_at` that is already more than 15 minutes in the past is never shown as an upcoming "Next run": `cron status` prints `⚠ Next run <time> is OVERDUE — passed 7h ago but the job has not fired`, `cron list` and the in-chat `/cron list` label the row `Overdue:`, the dashboard and the Desktop cron panel (including a Bot's Routines card) show `Overdue since`, and when the scheduler has stopped ticking `status` (with the gateway down) and the dashboard Cron page also say when it last ticked. That is the signature of a scheduler that stopped ticking — restart the gateway (`mibyan gateway restart`) so the next tick picks the overdue job up, or run it right away with `mibyan cron run <id>`.

For a named profile served by the default-profile multiplexer, `mibyan cron status` names that scheduler host and reports the named profile's own heartbeat health. Missing or stale heartbeats point to `mibyan --profile default gateway restart`. `cron list` and `cron create` also warn when that heartbeat is missing or stale; `cron status` additionally checks the last successful tick and reports tick errors.

### Gateway scheduler behavior

On each tick Mibyan:

1. loads jobs from `~/.mibyan/cron/jobs.json`
2. checks `next_run_at` against the current time
3. starts a fresh `AIAgent` session for each due job
4. optionally injects one or more attached skills into that fresh session
5. runs the prompt to completion
6. delivers the final response
7. updates run metadata and the next scheduled time

A file lock at `~/.mibyan/cron/.tick.lock` prevents overlapping scheduler ticks from double-running the same job batch.

### Restart-safe workers under systemd

When the gateway runs as a systemd service, each due job is handed to an external worker process launched in a transient user scope (`systemd-run --user --scope`), so restarting the gateway mid-job does not kill the job. Creating that scope needs a user systemd session; hosts without one (containers, minimal LXCs, a service user without linger) cannot provide it.

By default cron then **degrades**: the job still runs as a separate external process with the same execution handoff, but without cgroup isolation, so a gateway restart during the job kills it (the execution ledger records that). One warning is logged per gateway process. To fail closed instead — skip the job and record the error on the job row — set:

```yaml theme={null}
cron:
  require_restart_safe_scope: true
```

The lasting fix is a user session for the gateway user: `sudo loginctl enable-linger <gateway-user>` (and `XDG_RUNTIME_DIR` / `DBUS_SESSION_BUS_ADDRESS` in the unit for system-level installs), then restart the gateway. Kanban workers always require a scope under the managed gateway regardless of this key: a spawn the host cannot scope is recorded on the card as an infrastructure failure and retried later, never charged to the card (see the [Kanban docs](/desktop/user-guide/features/kanban#workers-and-systemd-cgroups)).

The worker is the gateway's own interpreter running `python -m cron.scheduler`, with the gateway's checkout pinned on its `PYTHONPATH` (plus any entries the gateway itself was started with), so it imports the same Mibyan tree the gateway runs — regardless of the venv's editable-install mapping, the unit's `WorkingDirectory`, or `PYTHONSAFEPATH` on the host. A worker that dies before acknowledging the handoff records its own stderr tail in the job's last error and in the execution ledger, so the failing import (or whatever killed it) is named instead of a bare exit code.

### Execution history

Mibyan records each claimed cron attempt in the profile-local
`~/.mibyan/cron/executions.db` before executor or provider dispatch. Attempts
move through `claimed`, `running`, and one immutable terminal state:
`completed`, `failed`, or `unknown`. After restart — and before every manual
`mibyan cron run` / `/cron run`, so a one-shot invocation with no scheduler
running heals the ledger too — Mibyan marks an abandoned attempt `unknown` only
when the original PID and process-start fingerprint prove that its owner is
gone. Unknown attempts are audit records and are never automatically rerun.

Inspect recent attempts with `mibyan cron runs [job-id] --limit 20` (alias:
`history`). Terminal history is bounded; active attempts are never pruned. The
ledger is included in quick backups.

Scheduled attempts also record their exact scheduled instant, separately from
the time they were claimed. If an old `jobs.json` snapshot re-arms an occurrence
that the retained ledger records as completed, Mibyan skips that replay and
re-anchors recurring jobs. This works even when the snapshot predates the
dispatch stamp or the original run started late. Explicit manual runs do not
consume a scheduled occurrence's identity.

This is not an exactly-once side-effect guarantee: legacy rows without an
identity, pruned history, unavailable ledgers, and interrupted attempts cannot
prove completion. Restoring the ledger itself to an older backup also removes
that evidence. External fire callbacks identify the currently accepted store
claim, not an upstream scheduled slot absent from the callback.

### Repeated-failure review nudge

Each job tracks a `failure_streak` — consecutive failed runs (delivery
failures don't count). A run that fails before the agent is reached at all —
a bad import after a half-applied update, a provider client that cannot be
constructed — counts and alerts the same as one the agent itself failed. When
a *recurring* job's streak reaches the threshold, the failure message
delivered to chat gains a review nudge telling you the job has failed N runs
in a row and suggesting you fix, pause (`mibyan cron pause <job>`), or remove
it. Any successful run resets the streak, and `mibyan cron list` shows the
streak alongside a failing job's last run. One-shot jobs never nudge.

```yaml theme={null}
cron:
  failure_nudge_threshold: 3   # default; 0 disables the nudge
```

### Automatic re-runs when the model was unreachable

A recurring job whose run fails with a transient network or DNS error before
a single model call was made — the classic case is a fire right after the
computer wakes, while the VPN or Wi-Fi is still reconnecting — does not sit
out a whole period. The scheduler re-runs it automatically after **5, 15, and
30 minutes** (inspired by Claude Cowork's scheduled-task re-runs), then falls
back to the normal schedule. Because zero API calls were made, the re-run is
spend-neutral and cannot duplicate any side effect.

While a re-run is pending, the interim failure notice is suppressed — you get
the real result when a re-run succeeds, or a normal failure alert once the
ladder is exhausted. Any run that reaches the model (success or failure)
resets the ladder. One-shot jobs are excluded: their dispatch accounting is
at-most-times and a consumed dispatch is never resurrected. Retries never
fire past the schedule's own next occurrence when that comes sooner.

```yaml theme={null}
cron:
  retry_unreachable: false   # default true; disables the automatic re-runs
```

### Holding a job through a closed provider usage window

The mirror case: the provider says exactly how long it will stay closed. When
the scheduler resolves a subscription provider (currently the OpenAI Codex
usage probe) and the provider reports its usage limit exhausted with a
`retry after <N>s` hint (often many hours), and the whole fallback chain is
unavailable, re-firing a sub-hourly job into that window is guaranteed to fail
identically on every tick — and to alert every time. A 429 the model API
returns mid-run is not held this way; it is retried on the normal cadence.

Instead, the scheduler **parks the job**: the one failure alert says the
window is closed and that the job is held. If the provider reopens well before
a **sparse** cron job's next natural occurrence (at least half a schedule
period early), a blocked scheduled occurrence retries once at that recovery
boundary; a second quota failure waits for the natural schedule. Dense
schedules, manual runs and interval jobs retain their natural next run.
Otherwise, missed occurrences are coalesced and
`next_run_at` moves to the first scheduled occurrence after the window. The
parked instant is stored as `quota_hold_until`; nothing fires or alerts before
it. Any run that reaches the model clears the hold. One-shot jobs are not held.

### Failure incidents: alert once, remind on a cooldown, acknowledge

A recurring job that keeps failing with the *same* error alerts you **once**,
not on every run. Each failure is recorded as a durable **incident**, keyed by
the job plus a normalized signature of the error text, in the same per-profile
ledger database as the execution history; the first failure of a signature is
always delivered, and repeats are then withheld while the incident is `alerted`
(the run is still recorded — `mibyan cron runs` and the failure streak see it,
only the ping is held back).

```yaml theme={null}
cron:
  failure_repeat_alert_hours: 6   # still broken after this long → one reminder ping,
                                  # then silent again; 0 = alert on every failing run
```

Anything that changes the picture alerts immediately: a *different* error mints
its own incident and pings at once, and a successful run re-arms the signature
so the same error after a green run alerts again. If the incident ledger cannot
be read, the ping is delivered rather than swallowed.

```bash theme={null}
mibyan cron incidents                 # list incidents (newest activity first)
mibyan cron incidents --state alerted # filter: detected | alerted | resolved | closed
mibyan cron incidents ack <id>        # acknowledge — silence this signature for good
```

Acknowledging an incident silences the failure ping for that exact signature
only, reminders included. Nothing else changes: the run history still records
every failure, the failure streak keeps counting, and the moment the job starts
failing with a *different* error a new incident is minted and alerts fire
again.

A successful run marks every open incident for that job `resolved`, so the
list reflects current health rather than every failure the job ever had. If
the job later fails with the *same* error, the resolved incident re-opens as
`detected` and you are alerted again. Acknowledged (`closed`) incidents are
the exception: a success leaves them alone, and a repeat stays silent.

Incident lifecycle: `detected` (failure recorded) → `alerted` (at least one
failure ping reached delivery; `alerted_at` is the latest one and starts the
reminder cooldown) → `resolved` (the job ran OK afterwards; re-opens on a
repeat) or `closed` (acknowledged; terminal for that signature). Stored error
text is secret-redacted and truncated before it is written.

### Fleet health check: `mibyan cron doctor`

`mibyan cron doctor` is a read-only health check over every active job. It prints grouped, per-job issues and exits `1` while any finding stands, including historical late or catch-up dispatches (`0` when no findings remain).

A successful catch-up does not clear the lateness warning; the next on-time dispatch does. A watchdog such as `mibyan cron doctor || alert` can therefore keep alerting for a full schedule interval after the host wakes, even if the catch-up succeeds.

```bash theme={null}
mibyan cron doctor
```

Checks per active job:

* last run failed (`last_status` not ok, with the recorded error),
* last delivery failed (the output was produced but never reached you),
* last dispatch was late or caught up after a missed schedule (`last_dispatch`); this warning clears at the next on-time fire,
* a scheduled fire could not reach the runner (`last_fire_error`), with the recorded timestamp and a shortened reason; this warning clears after a successful run,
* `next_run_at` missing, or parked in the past beyond a 15-minute ticker
  grace window — the "job is silently not firing" signal (scheduler dead,
  gateway down, or a wedged fire-claim),
* script missing, not a file, or resolving outside `mibyan_HOME/scripts`,
* `no_agent` job with no script,
* configured `workdir` that no longer exists.

Doctor never mutates jobs or state — it only reports. Pair it with
`mibyan cron incidents` (durable failure records) and `mibyan cron runs`
(attempt ledger) when digging into a flagged job.

## Delivery options

When scheduling jobs, you specify where the output goes:

| Option | Description | Example |
| - | - | - |
| `"origin"` | Back to where the job was created | Default on messaging platforms |
| `"local"` | Save to local files only (`~/.mibyan/cron/output/`) | Default on CLI |
| `"telegram"` | Telegram home channel | Uses `TELEGRAM_HOME_CHANNEL` |
| `"telegram:123456"` | Specific Telegram chat by ID | Direct delivery |
| `"telegram:-100123:17585"` | Specific Telegram topic | `chat_id:thread_id` format |
| `"discord"` | Discord home channel | Uses `DISCORD_HOME_CHANNEL` |
| `"discord:#engineering"` | Specific Discord channel | By channel name |
| `"slack"` | Slack home channel | |
| `"whatsapp"` | WhatsApp home | |
| `"signal"` | Signal | |
| `"matrix"` | Matrix home room | |
| `"mattermost"` | Mattermost home channel | |
| `"email"` | Email | |
| `"sms"` | SMS via Twilio | |
| `"homeassistant"` | Home Assistant | |
| `"dingtalk"` | DingTalk | |
| `"feishu"` | Feishu/Lark | |
| `"wecom"` | WeCom | |
| `"weixin"` | Weixin (WeChat) | |
| `"bluebubbles"` | BlueBubbles (iMessage) | |
| `"qqbot"` | QQ Bot (Tencent QQ) | |
| `"bot-chat"` | This profile's canonical Bot Chat — the bot reads the output and responds | Machine-local |
| `"bot-chat:research"` | Another local profile's Bot Chat | Validated at create time |
| `"all"` | Fan out to every connected home channel | Resolved at fire time |
| `"telegram,discord"` | Fan out to a specific set of channels | Comma-separated list |
| `"origin,all"` | Deliver to the origin **plus** every other connected channel | Combine any tokens |

The agent's final response is automatically delivered to the configured `deliver:` target — the agent does not send messages itself, so there is nothing to call in the cron prompt.

Delivered output is secret-redacted on the way out, on every lane: the platform message, the
session mirror (payload and the job name spliced around it), and a `bot-chat` turn. Credential
shapes (vendor-prefixed API keys, tokens, `KEY=value` assignments) are masked even when
`security.redact_secrets: false` — that setting governs your own logs, not what leaves the
machine — and a redactor failure replaces the payload rather than sending it unscanned.
Credential-named URL query parameters are not stripped (magic links and pre-signed URLs are
legitimate cron output), and user-chosen secrets with no recognisable shape are not detected.
The run document under `cron/output/<job_id>/` keeps the agent's response as written.

### Delivery failures are a distinct status

Execution and delivery are tracked separately. When the agent run succeeds but
the output never reaches the target (platform 5xx, rate limit, stale session,
adapter returned no positive evidence of a send), the job records
`last_status: delivery_failed` — never a plain `ok` — with the reason in
`last_delivery_error`. `mibyan cron list` shows it in yellow as
`delivery_failed: <reason>`, `mibyan cron doctor` reports it as a delivery
issue, and a manual `cronjob run` reports `success: false` with the delivery
error. A delivery failure does not count toward the job's `failure_streak`
(the agent did its job); the next fully successful run returns the status to
`ok`.

### Bot Chat delivery (`bot-chat`)

`bot-chat` delivers the output **into a profile's canonical "Bot Chat" session as a real message**. Unlike every other target — where the recipient is a human reading a channel — the recipient here is the bot itself: it receives the output as an incoming message, acts on anything that needs action, and responds in its chat. Use it when scheduled output should be *processed*, not just posted.

* `bot-chat` (bare) targets the job's own profile.
* `bot-chat:<profile>` targets another profile **on the same machine**. Names are validated against `mibyan profile list` when the job is created; profiles on other gateways or machines can never be targeted, so same-named profiles across machines are unambiguous.
* Each delivery costs the target bot one full agent turn — mind the schedule frequency.
* Composes with other targets (`bot-chat,telegram`) but is never included in `all`.
* If the canonical chat is open in a mailbox-capable Desktop/TUI backend, delivery is **durably queued immediately**, whether the bot is idle or busy. Only that live owner runs the incoming turn; cron does not start a competing CLI writer. If a CLI-only or older unsupported owner holds the chat, cron retains the never-started output under the sending profile's `cron/bot_chat_pending/<receipt-id>.json`. Later scheduler ticks deliver after that owner releases the chat, in admission order. Deferred work retains its admitted destination home and receipt ID even if the scheduler's launch root changes; a missing/renamed destination is not recreated or resolved to another profile. A `transferred` pending record points to the live-owner receipt, not a failed turn. Malformed JSON records are retained and logged without blocking other queued outputs. With no owner, the existing `mibyan chat -c "Bot Chat" --create-if-missing` lane remains available (normal session ownership checks still apply). That child uses the exact destination home already checked by cron, including custom roots; inherited `HOME` or a changed active profile cannot redirect it. Its whole environment is the **destination** profile's, as a standalone `mibyan -p <profile>` would build it: the sending gateway's `.env` settings, bridged `TERMINAL_*` policy, platform authorization gates and credentials are dropped, and the destination's own secrets are overlaid. A missing destination directory is refused before launch, not recreated. A deferred request is claimed before launching that lane; interruption or an uncertain subprocess result never causes an automatic resend.
* Never-started outputs have no TTL: if an unsupported owner never releases, they remain queued rather than being silently dropped. Receipts retain their payloads indefinitely. An unexpected delivery exception is logged and retained as `ambiguous`, without stopping sibling deliveries in that drain; claimed/ambiguous attempts are never automatically replayed.
* **Queued is not completed.** Cron records receipt IDs and `queued`/`claimed` statuses in `last_delivery_queued`, with delivery outcome `queued` (neither delivered nor failed). A successful job shows `delivery_queued`; genuine errors on other targets still take precedence as delivery failures. The bot may complete later. The durable receipt in the target profile's `runtime/bot_live_delivery/<receipt-id>.json` is authoritative; cron's historical status is not automatically refreshed.
* Rechecking the same execution inspects its existing receipt, even if the owner has disappeared. It never falls back to another writer after acceptance. `failed`, `cancelled`, or `ambiguous` receipts are not automatically replayed; inspect the chat and receipt before intentionally starting new work. Each new cron execution has a distinct delivery ID.

### Routing intent (`all`)

`all` lets you ship one cron job to every messaging channel you have configured, without having to enumerate them by name. It is **resolved at fire time**, so a job created before you wired up Telegram will pick up Telegram on the next tick after you set `TELEGRAM_HOME_CHANNEL`.

Semantics: `all` expands to every platform with a configured home channel. Zero is fine; the job simply produces no delivery targets and is recorded as a delivery failure upstream.

`all` composes with explicit targets. `origin,all` delivers to the origin chat *plus* every other connected home channel, de-duplicating by `(platform, chat_id, thread_id)`.

### Telegram cron topic (`TELEGRAM_CRON_THREAD_ID`)

When Telegram topic mode is enabled, the root DM is reserved as a system lobby — replies sent there are rebuffed with a lobby reminder and `reply_to_message_id` is dropped, so you cannot reply to a cron message that landed in the main chat.

Point cron at a dedicated forum topic instead:

1. In Telegram, open the bot DM and create a topic named e.g. `Cron`. Long-press the topic header → **Copy link**; the trailing integer is the topic's `message_thread_id`.
2. Set `TELEGRAM_CRON_THREAD_ID=<that id>` in your `.env`.

This applies only to cron deliveries. `TELEGRAM_HOME_CHANNEL_THREAD_ID` (used elsewhere, e.g. restart notifications) is unchanged. Explicit `deliver="telegram:chat_id:thread_id"` targets continue to win over the env var. Replies to cron messages now arrive in the existing topic session, so you can act on them directly.

### Response wrapping

By default, delivered cron output is wrapped with a header and footer so the recipient knows it came from a scheduled task:

```
Cronjob Response: Morning feeds
-------------

<agent output here>

Note: The agent cannot see this message, and therefore cannot respond to it.
```

To deliver the raw agent output without the wrapper, set `cron.wrap_response` to `false`:

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  wrap_response: false
```

### Push notifications (`cron.delivery.notify`)

Cron output is a *final* delivery, not a progress message, so by default it is
sent with the platform's notification flag set — on Telegram this means the
brief triggers a push even when the adapter's notification mode is `important`
(which otherwise sends with `disable_notification=true`, and users report the
silent brief as "never delivered"). To restore silent deliveries:

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  delivery:
    notify: false   # default: true
```

The flag rides both the text send and any media attachments, so a run never
pushes for one and stays silent for the other.

### Delivery confirmation and the `UNVERIFIED` state

A live-adapter delivery is logged as delivered only on positive evidence from
the adapter: an explicit `success` that is not a filtered drop
(`delivered: false`), plus a `message_id` or `raw_response`. A result carrying
`success` but neither piece of evidence — the shape Slack, Matrix and
Mattermost adapters return — is still accepted (it is not proof of failure),
but the run is recorded on the job as `last_delivery_unverified` and surfaces
in `mibyan cron list`:

```
⚠ Delivery UNVERIFIED: adapter acked slack:C0123456 without message_id/raw_response
```

and in `mibyan cron doctor` as `last delivery unverified (...)`. The marker is
cleared by the next run that delivers with evidence. An empty payload (no text
and no media) is never handed to an adapter; it fails closed and is reported in
`last_delivery_error` instead of being logged as delivered.

### Continuable jobs (reply to a cron delivery)

By default a cron delivery is fire-and-forget: the message is sent, but it does
not live in the chat's conversation history, so if you reply to it the agent
has no record of what it said. Set a job **continuable** and the delivered brief
becomes a conversation you can reply into — the agent has the brief in context
instead of asking "what is Task #2?".

Opt-in, **default off**. Enable globally in config, or per-job via the `cronjob`
tool's `attach_to_session` (which overrides the global setting for that one job):

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  mirror_delivery: false   # set true to make cron deliveries continuable
```

Behaviour is **thread-preferred**, scoped to the job's own conversation:

* **Thread-capable platforms** (Telegram topics, Discord/Slack/Matrix threads): each
  delivery opens its own dedicated thread and the brief is seeded into that
  thread's session, so a reply in-thread continues with full context. A
  recurring job (e.g. a daily brief) opens a fresh thread per run, keeping each
  delivery's follow-up discussion isolated.
* **DM-only platforms** (WhatsApp, Signal, SMS): no threads exist, so the brief
  is mirrored into the target DM session instead (the origin DM, or the home DM
  for fallback and bare-platform jobs) — the DM itself is the continuation surface.

Only the job's **own conversation** is ever touched:

* the **origin chat** the job was created in;
* the **home-channel fallback** when `deliver: origin` captured no origin (jobs
  created by scripts, or from a session on the request/response `api_server`
  platform, which cannot receive a delivery) — the
  user's primary conversation standing in for the origin;
* a job's **single explicit `platform:chat` target**, but only when the job
  itself opts in with `attach_to_session: true` — the job author declares that
  target a conversation. The global `mirror_delivery` flag alone never makes an
  explicitly-addressed chat continuable.

Broadcast expansions (`all`) are never made continuable. A user-written bare
platform name (`deliver: slack`) addresses that platform's home channel
deliberately and follows the same rules as the home-channel fallback above.
After upgrading, existing `deliver: <platform>` jobs with `cron.mirror_delivery: true`
can open a new thread per run on thread-capable platforms. Set `attach_to_session: false`
on a job to opt out of this thread-per-run behaviour.

The mirror is written as a labelled user turn (`[Cron delivery: <task name>]`), which keeps
the conversation history alternation-safe across all model providers.

#### Flat, in-channel continuation (Slack)

The thread-preferred behaviour above mints a dedicated thread on every
delivery. If you'd rather have a continuable job land **flat in the channel
timeline** — no thread — set the Slack **continuable surface** to `in_channel`:

```yaml theme={null}
# ~/.mibyan/config.yaml
slack:
  cron_continuable_surface: in_channel   # default: thread
  reply_in_thread: false                 # required pairing (see below)
  require_mention: false                 # so a plain reply continues the job
```

In `in_channel` mode the brief is delivered as an ordinary top-level channel
message (no thread is opened), and your reply continues the job via the
channel's shared session. Three settings work together:

* **`cron_continuable_surface: in_channel`** — skips thread creation on delivery.
* **`reply_in_thread: false`** (required) — makes the bot answer your reply
  *flat* in the channel and key it to the same whole-channel session the brief
  was seeded into. Without it the continuation still works but arrives in a
  thread (it falls back safely to thread-style continuation, never a dropped
  reply — the gateway logs a warning at startup so you can spot the mismatch).
* **`require_mention: false`** (or add the channel to `free_response_channels`)
  — so you can reply with a plain message; otherwise the bot only wakes when you
  `@`-mention it on each reply.

Because the continuation is the **whole-channel** session, it is shared: other
chatter in the channel — and a second continuable in-channel job — join the same
rolling conversation. That is inherent to "flat in a channel" and is the same
tradeoff `reply_in_thread: false` users already accept; use the default
`thread` surface when you want each delivery's follow-up isolated.

This is a Slack capability today. Other platforms accept the key but fall back
to the `thread` surface (their continuation primitives differ); the choice is
per-platform, set under each platform's config. It's a gateway-side config flag
— a `/restart` picks it up; no Slack app reinstall is needed.

<Note>
  **1:1 DMs**

  `cron_continuable_surface` is a **channel** setting — a 1:1 DM has no
  thread-vs-timeline split to choose between (the DM is already flat), so the key
  has no effect there. What governs whether a DM cron delivery is continuable is
  the separate, pre-existing knob **`slack.dm_top_level_threads_as_sessions`**:

  * **`false`** — all top-level DMs share one rolling DM session, so a continuable
    cron brief and your reply land in the **same** session and the job continues in
    context. This is what you want for continuable cron in a DM.
  * **`true`** (default) — each top-level DM message is its own session, so a reply
    to a delivered brief starts a *fresh* session that has no record of the brief.
    Continuation does not work in this mode (for cron or any other flat delivery).

  So for a continuable cron job delivered to a 1:1 DM, set
  `slack.dm_top_level_threads_as_sessions: false`. `cron_continuable_surface` is
  not required (and is ignored) for DMs.
</Note>

### Silent suppression

If the agent's final response contains `[SILENT]`, delivery is suppressed entirely. The output is still saved locally for audit (in `~/.mibyan/cron/output/`), but no message is sent to the delivery target.

This is useful for monitoring jobs that should only report when something is wrong:

```text theme={null}
Check if nginx is running. If everything is healthy, respond with only [SILENT].
Otherwise, report the issue.
```

Failed jobs always deliver regardless of the `[SILENT]` marker — only successful runs can be silenced. For quiet monitoring jobs, prompt the agent to reply with only `[SILENT]` when there is nothing to report.

### Declaring a failed run

Only runtime failures (exceptions, timeouts, an unreachable model) mark a run as failed. When the agent itself
finishes its turn but the work did not get done — for example a delegated subagent or a script it ran failed —
it can declare the run failed by putting `[CRON_FAILURE]` alone on the **first line** of its response, followed
by the explanation:

```text theme={null}
[CRON_FAILURE]
The nightly export subagent exited with "disk full"; no report was produced.
```

The run is then recorded as failed (`last_status`, failure streak, `mibyan cron runs` and `mibyan cron incidents`
all reflect it) and the failure notice is delivered like any other failed run. The full response is still saved
under `~/.mibyan/cron/output/` for triage. The marker is strict: mentioning or quoting `[CRON_FAILURE]` anywhere
else in a report leaves the run successful. Script-only (`no_agent`) jobs ignore it — a script signals failure
with a non-zero exit code.

## Script timeout

Pre-run scripts (attached via the `script` parameter) have a default timeout of 3600 seconds (1 hour). This bounds the **script only** — skill-based / LLM-driven jobs run on a separate inactivity budget and are not capped by this value. If your scripts need a different limit, you can change it:

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  script_timeout_seconds: 1800   # 30 minutes
```

Or set the `mibyan_CRON_SCRIPT_TIMEOUT` environment variable. The resolution order is: env var → config.yaml → 3600s default.

Cron also bounds post-run session and agent-resource cleanup. This happens after the LLM turn returns, so it is separate from the inactivity timeout. The default is 10 seconds per cleanup operation. If a storage or client finalizer stops returning, the scheduler logs an error, releases the job's in-flight guard, and allows later runs to dispatch instead of skipping that job forever.

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  cleanup_timeout_seconds: 10
```

Set `cleanup_timeout_seconds: 0` only to restore the legacy unbounded cleanup behavior.

## Media send timeout

When a cron delivery includes media attachments (a generated PDF, TTS audio, an exported report) sent through a live gateway adapter, each attachment upload is bounded by a timeout — 300 seconds by default. Large files on slow uplinks can need more:

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  media_send_timeout_seconds: 600   # 10 minutes per attachment
```

Or set the `mibyan_CRON_MEDIA_SEND_TIMEOUT` environment variable. The resolution order is: env var → config.yaml → 300s default. A timed-out attachment is recorded in the job's run status as a partial delivery failure (the text still delivers).

## Bot Chat delivery timeout

A `bot-chat` delivery runs a full agent turn in the target bot's chat, so its bound is minutes, not seconds — 600s by default:

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  bot_chat_delivery_timeout_seconds: 900
```

A timed-out delivery is recorded in `last_delivery_error`; the bot's turn may still complete on its own.

The cap bounds the bot's **turn** only. When that turn messages a teammate (`message_agent`), the delivery process stays alive afterwards — bounded by `terminal.oneshot_completion_wait_seconds` — so the teammate's reply can land in the Bot Chat; that wait is not part of the delivery and is never counted against, or cut short by, this cap.

## Standalone send timeout

When the live gateway adapter cannot deliver (or no gateway is running), a target is sent through the platform's standalone sender. That send is bounded by a wall-clock timeout — 60 seconds by default — so a transport that is mid-reconnect cannot pin the job run (and a pending restart drain behind it) indefinitely:

```yaml theme={null}
# ~/.mibyan/config.yaml
cron:
  standalone_send_timeout_seconds: 120
```

A timed-out send is recorded in `last_delivery_error` as `standalone send to <target> timed out after Ns`; the message may still land if the adapter had already accepted it.

## No-agent mode (script-only jobs)

For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass `no_agent=True` at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely:

```bash theme={null}
mibyan cron create "every 5m" \
  --no-agent \
  --script memory-watchdog.sh \
  --deliver telegram \
  --name "memory-watchdog"
```

Semantics:

* Script stdout (trimmed) → delivered verbatim as the message.
* **Empty stdout → silent tick**, no delivery. This is the watchdog pattern: "only say something when something is wrong".
* Non-zero exit or timeout → an error alert is delivered, so a broken watchdog can't fail silently.
* `{"wakeAgent": false}` on the last line → silent tick (same gate LLM jobs use).
* No tokens, no model, no provider fallback — the job never touches the inference layer.

`.sh` / `.bash` files run under `bash` from `PATH` when available, otherwise `/bin/bash` (important on Windows Git Bash). Anything else runs under the current Python interpreter (`sys.executable`). Scripts must resolve inside `$mibyan_HOME/scripts/` — relative names, absolute paths, and `~`-prefixed paths are accepted when the resolved target stays in that directory; paths that escape it are rejected. A Python `script` or `monitor_script` can also pin a user-managed venv (for packages the Mibyan runtime doesn't carry) by passing `--interpreter ~/venvs/.../bin/python` at create/edit time — see [Using your own Python environment](/desktop/guides/cron-script-only#using-your-own-python-environment). The Mibyan-managed venv stays Mibyan-owned; nothing is installed or restored automatically. The subprocess environment is sanitized, so provider API credentials and other Mibyan-managed secrets are **not** inherited by cron scripts.

#### Giving a script a credential

A script that must authenticate to an external service (an API token, a service-account key) gets it the same way terminal and `execute_code` children do — declare the variable name in the owning profile's `config.yaml` and define the value in that profile's `.env` (or an external [secret source](/desktop/user-guide/secrets/overview)):

```yaml theme={null}
terminal:
  env_passthrough:
    - MY_SERVICE_TOKEN
```

The variable is forwarded into the script's environment with the **owning profile's** value: for a job that belongs to a profile served by a multi-profile gateway or the Desktop/dashboard backend, the value is resolved through that profile's secret scope, never the launch profile's process environment, and that profile's own `.env` credentials never reach another profile's scripts. Mibyan-managed provider credentials (`OPENAI_API_KEY`, gateway tokens, …) cannot be declared — the sanitizer rejects them. On a single-profile install the script inherits what the gateway's `.env` put in the process environment, as before. Log presence (`set`/`MISSING`), never the value: script output is delivered verbatim.

### The agent sets these up for you

The `cronjob_manage` tool's schema exposes `no_agent` to Mibyan directly, so you can describe a watchdog in chat and let the agent wire it up:

```text theme={null}
Ping me on Telegram if RAM is over 85%, every 5 minutes.
```

Mibyan will write the check script to `~/.mibyan/scripts/` via `write_file`, then call:

```python theme={null}
cronjob(action="create", schedule="every 5m",
        script="memory-watchdog.sh", no_agent=True,
        deliver="telegram", name="memory-watchdog")
```

It picks `no_agent=True` automatically when the message content is fully determined by the script (watchdogs, threshold alerts, heartbeats). The same tool also lets the agent pause, resume, edit, and remove jobs — so the whole lifecycle is chat-driven without anyone touching the CLI.

See the [Script-Only Cron Jobs guide](/desktop/guides/cron-script-only) for worked examples.

## Chaining jobs with `context_from`

Cron jobs run in isolated sessions with no memory of previous runs. But sometimes one job's output is exactly what the next job needs. The `context_from` parameter wires that connection automatically — Job B's prompt gets Job A's most recent output prepended as context at runtime.

```python theme={null}
# Job 1: Collect raw data
cronjob(
    action="create",
    prompt="Fetch the top 10 AI/ML stories from Hacker News. Save them to ~/.mibyan/data/briefs/raw.md in markdown format with title, URL, and score.",
    schedule="0 7 * * *",
    name="AI News Collector",
)

# Job 2: Triage — receives Job 1's output as context
# Get Job 1's ID from: cronjob(action="list")
cronjob(
    action="create",
    prompt="Read ~/.mibyan/data/briefs/raw.md. Score each story 1–10 for engagement potential and novelty. Output the top 5 to ~/.mibyan/data/briefs/ranked.md.",
    schedule="30 7 * * *",
    context_from="<job1_id>",
    name="AI News Triage",
)

# Job 3: Ship — receives Job 2's output as context
cronjob(
    action="create",
    prompt="Read ~/.mibyan/data/briefs/ranked.md. Write 3 tweet drafts (hook + body + hashtags). Deliver to telegram:7976161601.",
    schedule="0 8 * * *",
    context_from="<job2_id>",
    name="AI News Brief",
)
```

**How it works:**

* When Job 2 fires, Mibyan reads Job 1's most recent output from `~/.mibyan/cron/output/{job1_id}/*.md`
* That output is prepended to Job 2's prompt automatically
* Job 2 doesn't need to hardcode "read this file" — it receives the content as context
* The chain can be any length: Job 1 → Job 2 → Job 3 → ...

**What `context_from` accepts:**

| Format | Example |
| - | - |
| Single job ID (string) | `context_from="a1b2c3d4"` |
| Multiple job IDs (list) | `context_from=["job_a", "job_b"]` |

Outputs are concatenated in the order listed.

**Continuity: carry the previous run's output**

Set `continuity=true` and the job injects its *own* most recent output into each run. Recurring jobs normally start every run with amnesia — a news scout re-reports the same stories, a monitor re-alerts on the same condition. With continuity on, the job wakes up seeing what it reported last time and can dedupe and continue where it left off:

```python theme={null}
cronjob(
    action="create",
    prompt="Scan HN and arXiv for new agent-tooling papers. Report only items NOT already covered in your previous run's output.",
    schedule="every 6h",
    continuity=True,
    name="Agent Tooling Scout",
)
```

The first run has no previous output, so the prompt runs as-is. Silent monitor ticks (`no_change`), empty output, and `wakeAgent=false` audit records are skipped when selecting context, so a quiet period preserves the latest substantive output. Audit files remain on disk. Error documents remain eligible to give the next run recovery context; this is not a success-only history filter. On later runs the previous output is prepended with continuity framing ("avoid repeating what was already reported"). It combines freely with upstream jobs (`context_from=["<other_job_id>"]` plus `continuity=true`), and `continuity=false` on update turns it off while preserving other `context_from` entries. Internally the flag is stored as the reserved `self` entry in `context_from`.

From the CLI: `mibyan cron create "every 6h" "Scan for news" --continuity`, and `mibyan cron edit <job_id> --continuity` / `--no-continuity` to toggle it on an existing job. The same toggle appears in the dashboard's cron editor and the desktop Bot Mode routine dialog.

**When to use it:**

* Multi-stage pipelines (collect → filter → format → deliver)
* Dependent tasks where step N's work depends on step N−1's output
* Fan-out/fan-in patterns where one job aggregates results from several others
* Recurring scouts/monitors that should dedupe against their own previous report (`continuity=true`)

## Provider recovery

If the primary API key is rate-limited or the provider returns an error, the cron agent can:

* **Rotate to the next credential** in your [credential pool](/desktop/user-guide/configuration#credential-pool-strategies) for the same provider. This applies to every job, pinned or not.
* **Fall back to an alternate provider** from `fallback_providers` (or the legacy `fallback_model`) in `config.yaml` — **unpinned jobs only**. That covers a failure while resolving credentials before the run starts and a provider error mid-run.

A job with its own `provider`, `model` or `base_url` (set with `--provider` / `--model`, `--pin`, the dashboard, or `jobs.json`) never falls back to the global chain. The pin says which route the job runs on, and a fallback entry is a different provider and usually a different model, so when the pinned route fails the run fails and the failure alert says so. This is the same rule [subagent delegation](/desktop/user-guide/features/delegation) applies to a pinned child. To keep fallback for a job, leave it unpinned: it follows `cron.model` / `cron.model_provider` (or the main model) and walks the chain like any other unpinned job.

Before this rule, a pinned job whose provider failed could run on the first working `fallback_providers` entry instead, with a one-line notice in its output. If you relied on that, unpin the job (`mibyan cron edit <job_id> --unpin`) and set the model through `cron.model` instead.

A single rate-limited key therefore does not fail a run that has another credential for the same provider, and unpinned jobs still survive a provider outage when a chain is configured.

## Run failures (`last_error`)

A failed agent run records a concise `last_error`, visible in job listings and `/cron list`
with credential patterns and URL credentials redacted (including previously stored errors).
This is separate from `last_fire_error` (scheduler handoff) and `last_delivery_error` (delivery).
Those fields can correctly be empty when the agent itself failed.

For a connection failure, inspect the run document under `cron/output/<job_id>/` in the active
Mibyan home. Its `## Error` section includes the chained traceback, with credential patterns
and URL credentials redacted. The file uses the existing private output-file permissions;
traceback locals are not captured. Delivery notices and `last_error` retain the concise error,
not the full traceback. Review diagnostics before sharing: redaction is not a guarantee that
arbitrary application data is non-sensitive.

## Missed scheduled fires (`last_fire_error`)

On hosted (managed-cron) deployments, a scheduled fire travels from the platform scheduler through the dashboard to the gateway's internal API server. If that final hand-off fails — the gateway process is down, or its API-server listener never started — the run never begins, so there is no execution record and no `last_status` to inspect. The tell-tale shape: the job works every time you trigger it manually, but never auto-fires.

These misses are stamped on the job record as `last_fire_error` (timestamp + reason) and surfaced by:

* `cronjob_manage` tool → `action: "list"` — the `last_fire_error` field
* `mibyan cron list`: a red missed-fire warning under the job
* `mibyan cron doctor`: a per-job missed-fire finding that makes the command exit `1`
* The dashboard job view

The stamp always reflects **current** auto-fire health: it is overwritten by newer misses and cleared automatically by the next successful run. If you see it, the job and its schedule are fine — the gateway side of the fire path needs attention (most commonly, restart the gateway through its supervisor so it loads the full profile environment: `mibyan gateway restart`).

### Local missed-run policy

If the gateway was down (or restarting) when a recurring job's scheduled time
passed, the job **catches up once** when the scheduler is back: a slot missed
inside a restart gap fires exactly one time, a slot that already ran before the
restart is never run again, and a long outage collapses into a single run rather
than one run per missed slot. Paused jobs never catch up. Each catch-up shows in
`mibyan cron list` as `⚠ late` / `⚠ catch-up after missed fire`.

To avoid that catch-up load after a planned gateway stop, set:

```yaml theme={null}
cron:
  catch_up_missed: false   # default: true
```

Or run `mibyan config set cron.catch_up_missed false`. With this opt-out, a recurring
job later than its existing grace window (half its period, clamped to 120 seconds–2
hours) is re-anchored to its next future occurrence without firing now. The skip is
logged. Jobs inside grace and explicit manual triggers still run normally; if the
next occurrence cannot be computed, the existing run-once fallback is preserved.
This does not change one-shot expiry, resume behavior, or the hosted-provider sweep
below. There is no per-job override.

### Misfire catch-up

When an external scheduler provider is active (managed cron on hosted deployments), the gateway also runs a catch-up sweep: a job whose scheduled time passed with no fire delivered — and whose grace window has elapsed — is claimed and run locally, so an outage in the fire hand-off costs minutes instead of the whole day. The sweep is de-duplicated against late scheduler retries by the same store claim used for normal fires.

```yaml theme={null}
cron:
  misfire_grace_minutes: 10   # wait this long for the scheduler's own retries
                              # before catching up locally; 0 disables catch-up
```

Local (built-in ticker) deployments don't need this — the ticker already picks up past-due jobs on its next tick.

## Schedule formats

The agent's final response is automatically delivered to the job's `deliver:` target — the agent no longer fires messages itself, so the user-facing content simply goes in the final response. To deliver to **additional or different** targets, list multiple `deliver:` targets on the cron job (comma-separated, e.g. `deliver: "telegram,discord"`) rather than having the agent send them.

### Relative delays (one-shot)

```text theme={null}
in 30m  → Run once in 30 minutes
in 2h   → Run once in 2 hours
in 1d   → Run once in 1 day
```

### Intervals (recurring)

```text theme={null}
30m          → Every 30 minutes (bare durations are recurring)
every 30m    → Every 30 minutes
every 2h     → Every 2 hours
every 1d     → Every day
every hour   → Every hour (bare unit = 1)
```

### Natural day/time schedules (recurring)

```text theme={null}
every monday 9am         → Weekly, Mondays at 9:00 AM
every day at 9am         → Daily at 9:00 AM
weekdays at 9am          → Weekdays at 9:00 AM
weekends at 10am         → Saturdays and Sundays at 10:00 AM
daily at 7am             → Daily at 7:00 AM
monday, wednesday at 9am → Mondays and Wednesdays at 9:00 AM
```

Times accept `9am`, `9:30pm`, `14:00`, bare 24-hour hours (`at 7`), `noon`, and `midnight`. These forms compile to cron expressions internally (they require the `croniter` package, installed by default).

### Cron expressions

```text theme={null}
0 9 * * *       → Daily at 9:00 AM
0 9 * * 1-5     → Weekdays at 9:00 AM
0 9 * * MON-FRI → Weekdays at 9:00 AM (named weekdays/months accepted)
0 */6 * * *     → Every 6 hours
30 8 1 * *      → First of every month at 8:30 AM
0 0 * * 0       → Every Sunday at midnight
```

### ISO timestamps

```text theme={null}
2026-03-15T09:00:00    → One-time at March 15, 2026 9:00 AM
```

## Repeat behavior

| Schedule type | Default repeat | Behavior |
| - | - | - |
| One-shot (`in 30m`, timestamp) | 1 | Runs once |
| Interval (`every 2h`) | forever | Runs until removed |
| Cron expression | forever | Runs until removed |

You can override it:

```python theme={null}
cronjob(
    action="create",
    prompt="...",
    schedule="every 2h",
    repeat=5,
)
```

## Managing jobs programmatically

The agent-facing API is one tool:

```python theme={null}
cronjob(action="create", ...)
cronjob(action="list")
cronjob(action="update", job_id="...")
cronjob(action="pause", job_id="...")
cronjob(action="resume", job_id="...")
cronjob(action="run", job_id="...")
cronjob(action="remove", job_id="...")
```

For `update`, pass `skills=[]` to remove all attached skills.

### Manual runs are asynchronous

`cronjob(action="run")` fires the job immediately **in the background** (like
`delegate_task`): the tool call returns at once with a handle, and the job's
outcome — success/failure, delivery target, next scheduled run, and an output
excerpt — re-enters the conversation as a new message when the run finishes.
The agent (and you) can keep working in the meantime, and a job that is
already mid-run is refused with "already running" instead of double-firing.

You can also pass `prompt` with `action="run"` to inject transient per-run
context:

```python theme={null}
cronjob(action="run", job_id="...", prompt="CONTEXT: focus on the EU region today")
```

The context is appended to the job's stored prompt under a `## Run Context`
header for that single fire only — it is never persisted to the job
definition, and it passes the same prompt-injection scan as stored prompts.

Runtimes that can't receive detached results (one-shot `mibyan -z`, `mibyan
cron run` from the CLI, cron child sessions, Kanban workers) fall back to
synchronous execution automatically.

## Toolsets available to cron jobs

Cron runs each job in a fresh agent session with no chat platform attached. By default the cron agent gets **the toolset you configured for the `cron` platform in `mibyan tools`** — not the CLI default, not everything under the sun.

```bash theme={null}
mibyan tools
# → pick the "cron" platform in the curses UI
# → toggle toolsets on/off just like you would for Telegram/Discord/etc.
```

Tighter per-job control is available via the `enabled_toolsets` field on `cronjob.create` (or on an existing job via `cronjob.update`):

```text theme={null}
cronjob(action="create", name="weekly-news-summary",
        schedule="every sunday 9am",
        enabled_toolsets=["web", "file"],      # just web + file, no terminal/browser/etc.
        prompt="Summarize this week's AI news: ...")
```

When `enabled_toolsets` is set on a job it wins; otherwise the `mibyan tools` cron-platform config wins; otherwise Mibyan falls back to the built-in defaults. If the cron-platform toolset config cannot be read at all (for example a malformed `platform_toolsets` block in `config.yaml`), the run fails with a recorded error instead of quietly running with every tool — check `mibyan cron list` / `mibyan cron doctor`. This matters for cost control: carrying `browser`, `delegation` into every tiny "fetch news" job bloats the tool-schema prompt on every LLM call.

If the job drives a site you're logged into, the login has to be in place before the run — a scheduled tick has nobody to answer a prompt. [Scheduled and unattended runs](/desktop/user-guide/features/browser#scheduled-and-unattended-runs) covers that setup.

### Skipping the agent entirely: `wakeAgent`

If your cron job attaches a pre-check script (via `script=`), the script can decide at runtime whether Mibyan should even invoke the agent. Emit a final stdout line of the form:

```text theme={null}
{"wakeAgent": false}
```

…and cron skips the agent run entirely for this tick. Useful for frequent polls (every 1–5 min) that only need to wake the LLM when state actually changed — otherwise you pay for zero-content agent turns over and over.

```python theme={null}
# pre-check script
import json, sys
latest = fetch_latest_issue_count()
prev = read_state("issue_count")
if latest == prev:
    print(json.dumps({"wakeAgent": False}))   # skip this tick
    sys.exit(0)
write_state("issue_count", latest)
print(json.dumps({"wakeAgent": True, "context": {"new_issues": latest - prev}}))
```

When `wakeAgent` is omitted, the default is `true` (wake the agent as usual).

#### Recipes: cheap pre-run gates

The `wakeAgent` gate gives you a \$0 way to decide whether a scheduled job should spend any LLM tokens at all. Three patterns cover most use cases.

**File-change gate** — only run when a watched file has new content since the last successful tick. The scheduler records each job's `last_run_at`; compare it against the file's mtime.

```bash theme={null}
#!/usr/bin/env bash
# ~/.mibyan/scripts/feed-changed.sh
FEED="$HOME/data/feed.json"
STATE="$HOME/.mibyan/scripts/.feed-changed.last"
test -f "$FEED" || { echo '{"wakeAgent": false}'; exit 0; }
mtime=$(stat -c %Y "$FEED")
last=$(cat "$STATE" 2>/dev/null || echo 0)
if [ "$mtime" -le "$last" ]; then
  echo '{"wakeAgent": false}'
else
  echo "$mtime" > "$STATE"
  echo '{"wakeAgent": true}'
fi
```

```text theme={null}
cronjob(action="create", name="process-feed",
        schedule="every 30m",
        script="feed-changed.sh",
        prompt="A new ~/data/feed.json has landed. Summarize what changed.")
```

**External-flag gate** — only run when some other process has signalled readiness (e.g. a deploy hook drops a file, a CI job sets a value in your state store).

```bash theme={null}
#!/usr/bin/env bash
# ~/.mibyan/scripts/flag-ready.sh
if test -f ~/.mibyan/cache/scratch/new-data-ready; then
  rm -f ~/.mibyan/cache/scratch/new-data-ready
  echo '{"wakeAgent": true}'
else
  echo '{"wakeAgent": false}'
fi
```

```text theme={null}
cronjob(action="create", name="nightly-analysis",
        schedule="0 9 * * *",
        script="flag-ready.sh",
        prompt="Run the nightly analysis over today's batch.")
```

**SQL-count gate** — only run when there are new rows to process in your own database. The script can also pass the count through to the agent via `context`, so the agent knows how much it's looking at without re-querying.

```python theme={null}
#!/usr/bin/env python
# ~/.mibyan/scripts/new-rows.py
import json, sqlite3
conn = sqlite3.connect("/home/me/data/app.db")
n = conn.execute(
    "SELECT COUNT(*) FROM messages WHERE ts > strftime('%s','now','-2 hours')"
).fetchone()[0]
if n < 1:
    print(json.dumps({"wakeAgent": False}))
else:
    print(json.dumps({"wakeAgent": True, "context": {"new_rows": n}}))
```

```text theme={null}
cronjob(action="create", name="summarize-new-msgs",
        schedule="every 2h",
        script="new-rows.py",
        prompt="Summarize the new messages from the last 2 hours.")
```

The same pattern works for any data source you can query from a script — Postgres, an HTTP API, your own state store — without baking a SQL evaluator into the cron subsystem.

<Tip>
  Mibyan's own `~/.mibyan/state.db` is an internal schema that changes between releases. Don't query it from a pre-run gate — point at your own database or feed instead.
</Tip>

Credit: this recipe set was prompted by @iankar8's exploration in [#2654](https://github.com/NousResearch/hermes-agent/pull/2654), which proposed adding sql/file/command triggers as a parallel mechanism. The `script` + `wakeAgent` gate already covers all three cases at \$0, so the work landed as documentation instead.

### Chaining jobs: `context_from`

A cron job can consume the most recent successful output of one or more other jobs by listing their names (or IDs) in `context_from`:

```text theme={null}
cronjob(action="create", name="daily-digest",
        schedule="every day 7am",
        context_from=["ai-news-fetch", "github-prs-fetch"],
        prompt="Write the daily digest using the outputs above.")
```

The referenced jobs' most recent completed outputs are injected above the prompt as context for this run. Each upstream entry must be a valid job ID or name (see `cronjob action="list"`). Note: chaining reads the *most recent completed* output — it does not wait for upstream jobs that are running in the same tick.

## Job storage

Jobs are stored in `~/.mibyan/cron/jobs.json`. Output from job runs is saved to `~/.mibyan/cron/output/{job_id}/{timestamp}.md`.

Job definitions are plain JSON on disk: they survive `mibyan update`, gateway restarts, and machine reboots. A job that was mid-run during a restart is marked `unknown` in the execution ledger — it is not automatically retried, but the job's next scheduled tick fires normally. See [Execution history](#execution-history) for details.

<Tip>
  Ask the agent to manage jobs through the `cronjob_manage` tool, `mibyan cron edit`, or `/cron` — not by patching `jobs.json` directly. Direct edits can fail silently when [file write safety](/desktop/user-guide/security#file-write-safety) blocks the path (for example when `mibyan_WRITE_SAFE_ROOT` is set), and the [file-mutation verifier](/desktop/user-guide/configuration#file-mutation-verifier) footer is the authoritative signal that nothing was saved.
</Tip>

If a hand edit leaves `jobs.json` malformed, the scheduler repairs it on the next load instead of stopping: entries in the `jobs` list that are not JSON objects are dropped, and a `repeat.completed` that is not a non-negative integer is reset to a valid count (0 when it can't be read). Each repair is logged as a warning (value types only, never contents).

Jobs may store `model` and `provider` as `null`. When those fields are omitted, Mibyan resolves them at execution time from the global configuration. They only appear in the job record when a per-job override is set.

The storage uses atomic file writes so interrupted writes do not leave a partially written job file behind.

## Self-contained prompts still matter

<Warning>
  **Important**

  Cron jobs run in a completely fresh agent session. The prompt must contain everything the agent needs that is not already provided by attached skills.
</Warning>

**BAD:** `"Check on that server issue"`

**GOOD:** `"SSH into server 192.168.1.100 as user 'deploy', check if nginx is running with 'systemctl status nginx', and verify https://example.com returns HTTP 200."`

## Security

Scheduled task prompts are scanned for prompt-injection and credential-exfiltration patterns at creation and update time. Prompts containing invisible Unicode tricks, SSH backdoor attempts, or obvious secret-exfiltration payloads are blocked.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.