Skip to main content
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.
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 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.
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.
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.
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.
Cron-run sessions cannot recursively create more cron jobs. Mibyan disables cron management tools inside cron executions to prevent runaway scheduling loops.

Creating scheduled tasks

In chat with /cron

From the standalone CLI

Through natural conversation

Ask Mibyan normally:
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):
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:
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

Multiple skills

Skills are loaded in order. The prompt becomes the task instruction layered on top of those skills.
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:
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
IsolationEach 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.

Editing jobs

You do not need to delete and recreate jobs just to change them.
Job referenceThe <job_id> placeholder below (and in 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.

Chat

Standalone CLI

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

Standalone CLI

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 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:
--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:
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.
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:
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). 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.

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.

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).
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.
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.
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: 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:
To deliver the raw agent output without the wrapper, set cron.wrap_response to 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:
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:
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):
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:
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.
1:1 DMscron_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.

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:
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:
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:
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.
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:
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:
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:
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:
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. 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):
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:
Mibyan will write the check script to ~/.mibyan/scripts/ via write_file, then call:
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 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.
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: 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:
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 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 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:
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.
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)

Intervals (recurring)

Natural day/time schedules (recurring)

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

ISO timestamps

Repeat behavior

You can override it:

Managing jobs programmatically

The agent-facing API is one tool:
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:
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.
Tighter per-job control is available via the enabled_toolsets field on cronjob.create (or on an existing job via cronjob.update):
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 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:
…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.
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.
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).
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.
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.
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.
Credit: this recipe set was prompted by @iankar8’s exploration in #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:
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 for details.
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 blocks the path (for example when mibyan_WRITE_SAFE_ROOT is set), and the file-mutation verifier footer is the authoritative signal that nothing was saved.
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

ImportantCron jobs run in a completely fresh agent session. The prompt must contain everything the agent needs that is not already provided by attached skills.
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.