odek can run agent tasks on a cron schedule and deliver each result somewhere —
a Telegram chat, stdout, or a log file. The scheduler is native and
in-process: it runs inside a long-lived odek process that has already
resolved its configuration (API key, model, bot token, default chat) into
memory. A scheduled task therefore sees exactly what an interactive odek run
does — no environment-inheritance problems, no external cron daemon, no
container-only behaviour.
# A weekday stand-up nudge delivered to Telegram
odek schedule add --cron "0 9 * * 1-5" --deliver telegram "Remind me: stand-up in 15 minutes"
# A weekly proactive-nudge digest from Extended Memory (opt in with
# memory.extended.proactive_nudges_enabled: true first)
odek schedule add --cron "0 9 * * 1" --deliver telegram "Review my proactive nudges (open questions, stale goals) and summarize what deserves attention this week"
# Run the scheduler (headless), or just start `odek telegram` — it hosts one too
odek schedule daemonThe same engine runs in two places; pick whichever fits your deployment:
| Use when | |
|---|---|
Inside odek telegram |
You already run the bot. The scheduler starts automatically as part of the bot process — one process for chat + reminders. |
odek schedule daemon |
You don't run the bot (headless server, CI box). A dedicated foreground process that only schedules. |
A shared lock (~/.odek/schedule.pid) coordinates the two so jobs never fire
twice — but the two sides handle contention differently: if a daemon already
holds the lock, the bot's embedded scheduler defers silently (the bot keeps
running, just without scheduling); if the bot holds it, a standalone
odek schedule daemon refuses to start and exits non-zero. (Disable the
bot's embedded scheduler with schedules.enabled = false if you prefer to run
the daemon separately.)
Both also host the storage-maintenance janitor, which expires old sessions, audit records, and plans and rotates oversized logs — see MAINTENANCE.md.
odek schedule list List jobs: id, on/off, cron, next fire, last status
odek schedule add --cron "<expr>" <task> Add a job (flags below)
odek schedule rm <id> Remove a job
odek schedule enable <id> Enable a job
odek schedule disable <id> Disable a job (kept, never fires)
odek schedule run <id> Run a job once now and deliver (test it)
odek schedule next <id|"cron-expr"> Preview the next few fire times
odek schedule daemon Run the scheduler in the foreground
| Flag | Meaning |
|---|---|
--cron "<expr>" |
5-field cron or @macro (required) |
--name <label> |
Human label (defaults to the first words of the task) |
--deliver <dest> |
stdout (default), log, telegram, or telegram:<chatID> |
--tz <IANA> |
Timezone, e.g. Europe/Berlin (default UTC) |
--catchup |
If a fire was missed while the process was down, run once on startup |
--disabled |
Add without enabling |
Definitions are stored in ~/.odek/schedules.json (mode 0600); runtime state
(last run, status, next fire) lives in ~/.odek/schedule-state.json. A running
scheduler picks up edits to the definitions file automatically (no restart).
When you run odek telegram, the same jobs can be managed from inside the chat
— no shell access needed. Two slash commands mirror the CLI:
/schedules List jobs (id, on/off, cron, next fire, last status)
/schedule add <cron> <task> [| opts] Add a job (delivered to this chat by default)
/schedule view <id> Show a job's full detail + recent status
/schedule next <id|cron> Preview the next few fire times
/schedule run <id> Run a job once now, in this chat
/schedule enable | disable <id> Toggle a job
/schedule rm <id> Remove a job
Because a cron expression has a fixed shape, add needs no quoting: an
@macro is one token and a classic expression is exactly five fields; the
rest of the line is the task. Options come after a literal |:
/schedule add 0 9 * * 1-5 Summarize my unread email
/schedule add @daily Daily standup digest | tz=Europe/Berlin name=standup
/schedule add */15 9-17 * * 1-5 Check the build | deliver=telegram catchup
| Option (after |) | Meaning |
|---|---|
| deliver=<dest> | stdout, log, telegram, or telegram:<chatID>. Default: this chat. |
| tz=<IANA> | Per-job timezone, e.g. Europe/Berlin |
| name=<label> | Human label (single token; default: first words of the task) |
| catchup | Run a missed fire once on startup |
| disabled | Add without enabling |
Notes:
- Delivery defaults to the current chat (unlike the CLI, which defaults to
stdout) — adding a job from a conversation sends its results back there. /schedule runexecutes the task now, in this chat, through the normal agent pipeline (you see progress and answer any approval prompts) — a safe way to test a job. It does not change the job's own schedule or delivery.- Edits made from Telegram take effect immediately (the embedded scheduler reconciles on the spot, not on the ~30 s poll).
- Only chats/users on the bot's allowlist (
ODEK_TELEGRAM_ALLOWED_CHATS/ALLOWED_USERS) reach these commands. - Schedule management is further restricted to operator chats/users.
Mutating commands (
add,rm,enable,disable,run) are allowed only from the IDs listed inschedules.telegram_admin_chats/schedules.telegram_admin_users. These same IDs also authorize/restart. When neither list is configured, the bot falls back totelegram.default_chat_id; if that is also unset, mutating commands and/restartare rejected (read-onlylist/view/nextstill work). To keep schedule management CLI-only entirely, setschedules.allow_telegram_management = false.
Standard 5-field Vixie cron:
┌ minute 0-59
│ ┌ hour 0-23
│ │ ┌ day-of-month 1-31
│ │ │ ┌ month 1-12 or JAN-DEC
│ │ │ │ ┌ day-of-week 0-6 or SUN-SAT (0 and 7 are both Sunday)
* * * * *
Each field accepts *, a value, a range a-b, a step */n / a-b/n / a/n,
and comma-separated lists. Macros: @hourly, @daily (@midnight),
@weekly, @monthly, @yearly (@annually).
Granularity is one minute (no seconds field). Times are in the job's --tz
or, failing that, the scheduler's default timezone (UTC unless configured).
Day-of-month / day-of-week coupling follows Vixie semantics: when both
fields are restricted, a day matches if either matches. So 0 0 13 * 5 fires
on the 13th or any Friday — not only Friday the 13th.
odek schedule next "0 9 * * 1-5" # validate an expression and see upcoming fires| Kind | Result goes to |
|---|---|
stdout |
the daemon's stdout (or the bot's container logs) |
log |
appended to ~/.odek/schedule.log |
telegram |
the configured telegram.default_chat_id |
telegram:<chatID> |
a specific chat |
Telegram delivery needs telegram.bot_token and a chat ID
(ODEK_TELEGRAM_BOT_TOKEN + ODEK_TELEGRAM_DEFAULT_CHAT_ID, or per-job
telegram:<chatID>). When delivering from inside odek telegram, the live bot
client is reused (shared rate limiting).
A scheduled task runs with no human present to approve actions. The
headless runner always applies a hard "deny" floor for prompt-class operations
and clamps destructive, code-execution, install, system-write, network-egress,
and unknown/blocked risk classes to deny — regardless of the configured
dangerous profile. This prevents a compromised task definition from erasing
files or exfiltrating data while unattended.
Read/summarise/deliver tasks work as usual. If you truly need a scheduled job
that performs high-risk operations, run it interactively via odek run or the
/schedule run command so an approver can review each action.
Task definitions in schedules.json are owner-authored (same trust level as
config.json); the file is written 0600. Results written to
~/.odek/schedule.log are redacted for secrets before they hit disk.
The schedules config section (in ~/.odek/config.json or ./odek.json) tunes
the engine. Every field also has an ODEK_SCHEDULES_* environment override.
{
"schedules": {
"enabled": true,
"max_concurrent": 2,
"timezone": "UTC",
"catchup": false,
"allow_telegram_management": true,
"telegram_admin_chats": [123456789],
"telegram_admin_users": [987654321]
}
}| Field | Env | Default | Meaning |
|---|---|---|---|
enabled |
ODEK_SCHEDULES_ENABLED |
true |
Run the embedded scheduler inside odek telegram |
max_concurrent |
ODEK_SCHEDULES_MAX_CONCURRENT |
2 |
Max jobs running at once |
timezone |
ODEK_SCHEDULES_TIMEZONE |
UTC |
Default timezone for jobs without --tz |
catchup |
ODEK_SCHEDULES_CATCHUP |
false |
Global default for the missed-run policy |
allow_telegram_management |
ODEK_SCHEDULES_ALLOW_TELEGRAM_MANAGEMENT |
true |
Allow the in-chat /schedule commands to add/remove/toggle/run jobs (read-only listing always works) |
telegram_admin_chats |
ODEK_SCHEDULES_TELEGRAM_ADMIN_CHATS |
[] |
Operator chat IDs that may use mutating /schedule commands |
telegram_admin_users |
ODEK_SCHEDULES_TELEGRAM_ADMIN_USERS |
[] |
Operator user IDs that may use mutating /schedule commands |
If the scheduler was down when a job was due, on startup it either skips
(default — reschedules forward and records a skipped status) or runs once
(when the job's --catchup or schedules.catchup is set). A burst of missed
ticks never stampedes: at most one catch-up fire per job.