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.
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_jobset 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.
cronjob_manage tool, so you can create, pause, edit, and remove jobs by asking in plain language — no CLI required.
Creating scheduled tasks
In chat with /cron
From the standalone CLI
Through natural conversation
Ask Mibyan normally: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_providerschain 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/origintargets are never checked), - every MCP server the job names in its own
enabled_toolsetsresolved 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.
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):
mibyan config set cron.preflight false
Moving unpinned jobs to a new global default
An unpinned job follows the main agent model, somibyan 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.Running a job inside a project directory
Cron jobs default to running detached from any repo — noAGENTS.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:
workdir is set:
AGENTS.md,CLAUDE.md, and.cursorrulesfrom that directory are injected into the system prompt (same discovery order as the interactive CLI)terminal,read_file,write_file,patch,search_files, andexecute_codeall 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 ""(orworkdir=""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.Chat
Standalone CLI
- repeated
--skillreplaces the job’s attached skill list --add-skillappends to the existing list without replacing it--remove-skillremoves specific attached skills--clear-skillsremoves all attached skills
Lifecycle actions
Cron jobs now have a fuller lifecycle than just create/remove.Chat
Standalone CLI
pause— keep the job but stop scheduling itresume— 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 whencron.catch_up_missed: false) instead of silently jumping to the next occurrence; otherwise the next future run is computedrun— trigger the job on the next scheduler tickremove— delete it entirelyedit— modify schedule, prompt, delivery, etc.
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 thecronjob_manage tool —
a scheduled job cannot create, edit, or remove other jobs. Opt in via
config.yaml:
- One flat, user-owned table. Jobs created from a cron run land in the
same
jobs.jsonas 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: originfrom inside one is resolved at create time to the creating job’s own concrete target (platform:chat_id[:thread_id], orlocalif 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 ascompleted; the job record and itscron/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.
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:- loads jobs from
~/.mibyan/cron/jobs.json - checks
next_run_atagainst the current time - starts a fresh
AIAgentsession for each due job - optionally injects one or more attached skills into that fresh session
- runs the prompt to completion
- delivers the final response
- updates run metadata and the next scheduled time
~/.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:
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 afailure_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 aretry 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 isalerted
(the run is still recorded — mibyan cron runs and the failure streak see it,
only the ping is held back).
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.
- last run failed (
last_statusnot 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_atmissing, 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_agentjob with no script,- configured
workdirthat no longer exists.
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 recordslast_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 againstmibyan profile listwhen 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 inall. - 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. Atransferredpending 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 existingmibyan chat -c "Bot Chat" --create-if-missinglane remains available (normal session ownership checks still apply). That child uses the exact destination home already checked by cron, including custom roots; inheritedHOMEor a changed active profile cannot redirect it. Its whole environment is the destination profile’s, as a standalonemibyan -p <profile>would build it: the sending gateway’s.envsettings, bridgedTERMINAL_*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/claimedstatuses inlast_delivery_queued, with delivery outcomequeued(neither delivered nor failed). A successful job showsdelivery_queued; genuine errors on other targets still take precedence as delivery failures. The bot may complete later. The durable receipt in the target profile’sruntime/bot_live_delivery/<receipt-id>.jsonis 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, orambiguousreceipts 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:
- 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’smessage_thread_id. - Set
TELEGRAM_CRON_THREAD_ID=<that id>in your.env.
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: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:
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:
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 thecronjob
tool’s attach_to_session (which overrides the global setting for that one job):
- 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.
- the origin chat the job was created in;
- the home-channel fallback when
deliver: origincaptured no origin (jobs created by scripts, or from a session on the request/responseapi_serverplatform, which cannot receive a delivery) — the user’s primary conversation standing in for the origin; - a job’s single explicit
platform:chattarget, but only when the job itself opts in withattach_to_session: true— the job author declares that target a conversation. The globalmirror_deliveryflag alone never makes an explicitly-addressed chat continuable.
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 toin_channel:
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 tofree_response_channels) — so you can reply with a plain message; otherwise the bot only wakes when you@-mention it on each reply.
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 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).
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:
[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:
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 thescript 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:
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.
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: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
Abot-chat delivery runs a full agent turn in the target bot’s chat, so its bound is minutes, not seconds — 600s by default:
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: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 — passno_agent=True at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely:
- 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 andexecute_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):
.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
Thecronjob_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/scripts/ via write_file, then call:
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.
- 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 → …
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:
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 legacyfallback_model) inconfig.yaml— unpinned jobs only. That covers a failure while resolving credentials before the run starts and a provider error mid-run.
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_managetool →action: "list"— thelast_fire_errorfieldmibyan cron list: a red missed-fire warning under the jobmibyan cron doctor: a per-job missed-fire finding that makes the command exit1- The dashboard job view
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 inmibyan cron list as ⚠ late / ⚠ catch-up after missed fire.
To avoid that catch-up load after a planned gateway stop, set:
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.Schedule formats
The agent’s final response is automatically delivered to the job’sdeliver: 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)
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: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:
## 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 thecron platform in mibyan tools — not the CLI default, not everything under the sun.
enabled_toolsets field on cronjob.create (or on an existing job via cronjob.update):
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:
wakeAgent is omitted, the default is true (wake the agent as usual).
Recipes: cheap pre-run gates
ThewakeAgent 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.
context, so the agent knows how much it’s looking at without re-querying.
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:
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.
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
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."

