Schedule cron tasks
A cron task is a recurring agent prompt — “check the queue every 5 minutes”, “write a daily
report at 9am” — that fires on its own while octo serve is running, with nobody watching.
octo serveHow it works
Section titled “How it works”Each task is a JSON file in ~/.octo/tasks/, loaded by a scheduler that runs inside octo serve.
When a task fires, the scheduler runs one agent turn with the task’s prompt. Each run is bounded by
a 30-minute wall-clock timeout — the only hard cap.
Tasks only fire while octo serve is running. No serve, no runs — and a schedule missed while
the server was down is not replayed on restart.
Task fields
Section titled “Task fields”| Field | Required | Meaning |
|---|---|---|
name |
yes | Human-readable task name |
cron |
yes | Schedule expression — see below |
prompt |
yes | The prompt sent to the agent on each run |
model |
no | Model override; defaults to the server’s model |
agent_id |
no | Id of the expert the run executes as; empty = the Default Agent |
directory |
no | Working directory the run executes in |
notify |
no | IM chats to push each run’s final reply (or failure) to |
enabled |
yes | Whether the schedule is currently active |
The prompt runs in its own session with no access to whatever conversation created the task, so it needs to be self-contained: what to do, where, and what the output should look like. Give it an explicit stop condition too — an open-ended prompt keeps the model re-verifying until the 30-minute timeout instead of finishing once the answer is “nothing to report.”
Sessions, and a project per task
Section titled “Sessions, and a project per task”Every run creates a new session, titled with the run’s local date and time (e.g.
2026-07-22 15:04) and starting from an empty transcript — runs never share a session.
Each task also gets its own project, named after the task and working in
<workspace_dir>/<task name> (so ~/Octo/daily report by default), created the first time it is
needed. That is where the task’s runs execute their tools, and — because memory is scoped by project
— where the task’s own notes accumulate, separate from every other task on the machine. Set
directory on the task to point it somewhere else instead.
The directory is derived from the name once and then belongs to the project: renaming the task renames the project, not the directory. Moving it would either strand whatever the task has already written there or have to relocate it, and neither is what renaming asks for.
The project appears in the sidebar’s Projects section like any other, marked with a small clock, and can be renamed, configured, or deleted from there. Deleting it leaves the run sessions on disk as ordinary tasks; the next run creates the project again.
Cron expression — 6 fields, seconds first
Section titled “Cron expression — 6 fields, seconds first”The scheduler is robfig/cron with a seconds field — a standard 5-field crontab line is invalid here; always prepend a seconds field:
seconds minutes hours day-of-month month day-of-week| Want | Expression |
|---|---|
| Every day at 09:00 | 0 0 9 * * * |
| Every 30 minutes | 0 */30 * * * * |
| Weekdays at 18:30 | 0 30 18 * * 1-5 |
| 1st of each month at 08:00 | 0 0 8 1 * * |
Descriptors also work: @hourly, @daily, @weekly, @every 90m. Times are in the server’s
local timezone.
Editing a task in the Web UI
Section titled “Editing a task in the Web UI”Click the edit button on a task in the scheduler panel. The new session opens with an edit form prefilled with the task’s name, cron expression (restated in plain words), prompt, model, directory, and enabled state.
- Change the fields you want and save — the change takes effect right away. A bad or sub-hourly cron expression is rejected, not saved.
- Or describe the change in the box at the bottom of the form (“move it to 8am on weekdays”) and the agent makes it. Notifications and the expert a task runs as are changed this way too.
A field too long to fit the form without being cut off (a very long prompt, say) is left out of it — describe that change in the bottom box instead.
Managing tasks via the API
Section titled “Managing tasks via the API”Every change through the API reschedules the running process immediately — the recommended path
whenever octo serve is up.
# Create — returns {"id":"task_..."}. Any optional field (directory, model,# agent, notify) goes right in the create body.curl -s -X POST http://127.0.0.1:8088/api/tasks \ -H 'Content-Type: application/json' \ -d '{"name":"daily-report","cron":"0 0 9 * * *","prompt":"Summarize ...","directory":"/srv/repo"}'
curl -s http://127.0.0.1:8088/api/tasks # listcurl -s -X DELETE http://127.0.0.1:8088/api/tasks/{id} # delete
# Run now, out of schedulecurl -s -X POST http://127.0.0.1:8088/api/tasks/{id}/run
# Edit any subset of fields — this is also how you enable/disablecurl -s -X PATCH http://127.0.0.1:8088/api/tasks/{id} \ -H 'Content-Type: application/json' \ -d '{"prompt":"new prompt ...","enabled":false}'PATCH /api/tasks/{id} accepts name, enabled, cron, prompt, model, agent_id, directory,
notify — send only what you’re changing; renaming via name also renames the task’s project (its
directory stays put). The Web UI’s scheduler panel is a client of this
same API, so a task created by curl shows up there and vice versa; the panel is also the
recommended place to smoke-test a new task’s Run button rather than triggering
/api/tasks/{id}/run from a chat session — a run is a full agent turn (up to 30 minutes) in the
task’s own session, so firing it from a conversation just blocks that conversation while the
actual output lands somewhere nobody is watching it.
Without a running server
Section titled “Without a running server”Write ~/.octo/tasks/<id>.json directly (id format task_<unix-millis>; filename must equal
<id>.json):
{ "id": "task_1717999999999", "name": "daily-report", "cron": "0 0 9 * * *", "prompt": "Summarize ...", "directory": "/srv/repo", "enabled": true, "created_at": "2026-06-10T09:00:00Z"}The file is picked up the next time octo serve starts. A hand-written file with a bad cron
expression fails silently at load (logged to stderr only). File edits made while the server is
already running are ignored until restart — once it’s up, go through the API instead.
Keeping what a task collects
Section titled “Keeping what a task collects”A task that collects something each run — a price, a count, new items — can append it to a
named database with the sqlite tool (a SQLite file at ~/.octo/databases/<name>.db, created
on first use). A Light App can then read the same database by name and
chart it; the task never needs to know which app shows the data.
If the job needs no judgment on each run — fetch a page, parse it, insert the rows — ask the agent
to write it as a script for your OS scheduler (cron, launchd, Windows Task Scheduler) instead. It
costs no tokens per run and runs even while octo is closed. The script writes the same database,
and the agent has it record each run in a runs table so the app can show when it last ran and
whether it failed — an OS job that fails is otherwise silent.
Notifications
Section titled “Notifications”notify is a list of IM targets (a single bare object is also accepted); every entry gets pushed
the run’s final reply on success, or a short failure note on error. A failed push is logged on the
server and never affects the run itself.
| Platform | chat_id |
Notes |
|---|---|---|
feishu |
oc_… chat id |
Needs app creds in channels.yml; get the id from chat settings or the server log after messaging the bot |
dingtalk |
staff id (1:1) or cid… conversation id (group) |
A DM’s conversation id does not work — use the staff id |
weixin (iLink) |
user id | User must have messaged the bot at least once |
telegram |
chat id (user/group/channel) | Bot must already be able to message it |
discord |
channel id | Bot needs Send Messages permission there |
wecom |
ignored | Pushes go through a group-robot webhook bound to one group instead |
Next: for a shorter-lived, in-conversation repeat that doesn’t need to survive a restart, see
/loop instead.