Cron jobs (Gateway scheduler)
Cron vs Heartbeat? See Cron vs Heartbeat for guidance on when to use each.
Cron is the Gateway’s built-in scheduler. It persists jobs, wakes the agent at the right time, and can optionally deliver output back to a chat.
If you want “run this every morning” or “poke the agent in 20 minutes”, cron is the mechanism.
- Cron runs inside the Gateway (not inside the model).
- Jobs persist under
~/.kolo/cron/so restarts don’t lose schedules. - Two execution styles:
- Main session: enqueue a system event, then run on the next heartbeat.
- Isolated: run a dedicated agent turn in
cron:<jobId>, optionally deliver output.
- Wakeups are first-class: a job can request “wake now” vs “next heartbeat”.
Quick start (actionable)
Section titled “Quick start (actionable)”Create a one-shot reminder, verify it exists, and run it immediately:
kurahi cron add \ --name "Reminder" \ --at "2026-02-01T16:00:00Z" \ --session main \ --system-event "Reminder: check the cron docs draft" \ --wake now \ --delete-after-run
kurahi cron listkurahi cron run <job-id> --forcekurahi cron runs --id <job-id>Schedule a recurring isolated job with delivery:
kurahi cron add \ --name "Morning brief" \ --cron "0 7 * * *" \ --tz "America/Los_Angeles" \ --session isolated \ --message "Summarize overnight updates." \ --deliver \ --channel slack \ --to "channel:C1234567890"Tool-call equivalents (Gateway cron tool)
Section titled “Tool-call equivalents (Gateway cron tool)”For the canonical JSON shapes and examples, see JSON schema for tool calls.
Where cron jobs are stored
Section titled “Where cron jobs are stored”Cron jobs are persisted on the Gateway host at ~/.kolo/cron/jobs.json by default.
The Gateway loads the file into memory and writes it back on changes, so manual edits
are only safe when the Gateway is stopped. Prefer kurahi cron add/edit or the cron
tool call API for changes.
Beginner-friendly overview
Section titled “Beginner-friendly overview”Think of a cron job as: when to run + what to do.
-
Choose a schedule
- One-shot reminder →
schedule.kind = "at"(CLI:--at) - Repeating job →
schedule.kind = "every"orschedule.kind = "cron" - If your ISO timestamp omits a timezone, it is treated as UTC.
- One-shot reminder →
-
Choose where it runs
sessionTarget: "main"→ run during the next heartbeat with main context.sessionTarget: "isolated"→ run a dedicated agent turn incron:<jobId>.
-
Choose the payload
- Main session →
payload.kind = "systemEvent" - Isolated session →
payload.kind = "agentTurn"
- Main session →
Optional: deleteAfterRun: true removes successful one-shot jobs from the store.
Concepts
Section titled “Concepts”A cron job is a stored record with:
- a schedule (when it should run),
- a payload (what it should do),
- optional delivery (where output should be sent).
- optional agent binding (
agentId): run the job under a specific agent; if missing or unknown, the gateway falls back to the default agent.
Jobs are identified by a stable jobId (used by CLI/Gateway APIs).
In agent tool calls, jobId is canonical; legacy id is accepted for compatibility.
Jobs can optionally auto-delete after a successful one-shot run via deleteAfterRun: true.
Schedules
Section titled “Schedules”Cron supports three schedule kinds:
at: one-shot timestamp (ms since epoch). Gateway accepts ISO 8601 and coerces to UTC.every: fixed interval (ms).cron: 5-field cron expression with optional IANA timezone.
Cron expressions use croner. If a timezone is omitted, the Gateway host’s
local timezone is used.
Main vs isolated execution
Section titled “Main vs isolated execution”Main session jobs (system events)
Section titled “Main session jobs (system events)”Main jobs enqueue a system event and optionally wake the heartbeat runner.
They must use payload.kind = "systemEvent".
wakeMode: "next-heartbeat"(default): event waits for the next scheduled heartbeat.wakeMode: "now": event triggers an immediate heartbeat run.
This is the best fit when you want the normal heartbeat prompt + main-session context. See Heartbeat.
Isolated jobs (dedicated cron sessions)
Section titled “Isolated jobs (dedicated cron sessions)”Isolated jobs run a dedicated agent turn in session cron:<jobId>.
Key behaviors:
- Prompt is prefixed with
[cron:<jobId> <job name>]for traceability. - Each run starts a fresh session id (no prior conversation carry-over).
- A summary is posted to the main session (prefix
Cron, configurable). wakeMode: "now"triggers an immediate heartbeat after posting the summary.- If
payload.deliver: true, output is delivered to a channel; otherwise it stays internal.
Use isolated jobs for noisy, frequent, or “background chores” that shouldn’t spam your main chat history.
Payload shapes (what runs)
Section titled “Payload shapes (what runs)”Two payload kinds are supported:
systemEvent: main-session only, routed through the heartbeat prompt.agentTurn: isolated-session only, runs a dedicated agent turn.
Common agentTurn fields:
message: required text prompt.model/thinking: optional overrides (see below).timeoutSeconds: optional timeout override.deliver:trueto send output to a channel target.channel:lastor a specific channel.to: channel-specific target (phone/chat/channel id).bestEffortDeliver: avoid failing the job if delivery fails.preExec(CLI:--pre-exec <command>,--pre-exec-keep-tools,--pre-exec-pin-head, edit:--clear-pre-exec):{ "command": "...", "keepTools"?: true, "pinHead"?: true }. A deterministic pre-step: before the model turn the runtime runscommandthrough the agent’s ownexectool, exactly as if the agent had called exec with onlycommand(same tool policy, host, sandbox, security, allowlist, approvals and hardline guard; the child seesKOLO_EXEC_ORIGIN=cron). The model then gets the output and only reports on it; its turn has no tools unlesskeepToolsis true. A command that could not run (refused, not allowlisted, exec not allowed for the agent, or one that needs an approval, which nobody can give unattended) fails the job without a model turn. The run log entry keepspreExec: { status: ok|failed|error, exitCode, head, error, durationMs }, whereheadis the first output line, so the result is on record even when the model reports nothing. WithpinHead, that first line leads everything the job delivers, posts to the main session and logs as its summary (it is added in front unless the reply already starts with it), so a model reply can follow the command’s verdict but never replace or contradict it in what a person reads. The run log then also keepspreExec.pinned: trueandpreExec.modelSummary(the model’s own reply, at most 300 chars) for measuring the model.promptMode(CLI:--prompt-mode full|minimal, edit:--clear-prompt-mode): the system prompt for the turn.full(default) is the agent’s whole prompt.minimalis the subagent prompt: tools, safety, workspace and runtime, without the skills list, memory, messaging, reply-tag, voice, docs and self-update sections. It suits a single-tool ops job on a small local model, where the full prompt can take most of the context window. In a patch,nullrestores the default. CLI backends (claude-cli,codex-cli) build their own prompt and ignore it.
Isolation options (only for session=isolated):
postToMainPrefix(CLI:--post-prefix): prefix for the system event in main.postToMainMode:summary(default),full, ornone(post nothing back to main).postToMainMaxChars: max chars whenpostToMainMode=full(default 8000).
Model and thinking overrides
Section titled “Model and thinking overrides”Isolated jobs (agentTurn) can override the model and thinking level:
model: Provider/model string (e.g.,anthropic/claude-sonnet-4-20250514) or alias (e.g.,opus)thinking: Thinking level (off,minimal,low,medium,high,xhigh; GPT-5.2 + Codex models only)
Note: You can set model on main-session jobs too, but it changes the shared main
session model. We recommend model overrides only for isolated jobs to avoid
unexpected context shifts.
Resolution priority:
- Job payload override (highest)
- Hook-specific defaults (e.g.,
hooks.gmail.model) - Agent config default
Delivery (channel + target)
Section titled “Delivery (channel + target)”Isolated jobs can deliver output to a channel. The job payload can specify:
channel:whatsapp/telegram/discord/slack/mattermost(plugin) /signal/imessage/lastto: channel-specific recipient target
If channel or to is omitted, cron can fall back to the main session’s “last route”
(the last place the agent replied).
Delivery notes:
- If
tois set, cron auto-delivers the agent’s final output even ifdeliveris omitted. - Use
deliver: truewhen you want last-route delivery without an explicitto. - Use
deliver: falseto keep output internal even if atois present.
Target format reminders:
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g.
channel:<id>,user:<id>) to avoid ambiguity. - Telegram topics should use the
:topic:form (see below).
Telegram delivery targets (topics / forum threads)
Section titled “Telegram delivery targets (topics / forum threads)”Telegram supports forum topics via message_thread_id. For cron delivery, you can encode
the topic/thread into the to field:
-1001234567890(chat id only)-1001234567890:topic:123(preferred: explicit topic marker)-1001234567890:123(shorthand: numeric suffix)
Prefixed targets like telegram:... / telegram:group:... are also accepted:
telegram:group:-1001234567890:topic:123
JSON schema for tool calls
Section titled “JSON schema for tool calls”Use these shapes when calling Gateway cron.* tools directly (agent tool calls or RPC).
CLI flags accept human durations like 20m, but tool calls use epoch milliseconds for
atMs and everyMs (ISO timestamps are accepted for at times).
cron.add params
Section titled “cron.add params”One-shot, main session job (system event):
{ "name": "Reminder", "schedule": { "kind": "at", "atMs": 1738262400000 }, "sessionTarget": "main", "wakeMode": "now", "payload": { "kind": "systemEvent", "text": "Reminder text" }, "deleteAfterRun": true}Recurring, isolated job with delivery:
{ "name": "Morning brief", "schedule": { "kind": "cron", "expr": "0 7 * * *", "tz": "America/Los_Angeles" }, "sessionTarget": "isolated", "wakeMode": "next-heartbeat", "payload": { "kind": "agentTurn", "message": "Summarize overnight updates.", "deliver": true, "channel": "slack", "to": "channel:C1234567890", "bestEffortDeliver": true }, "isolation": { "postToMainPrefix": "Cron", "postToMainMode": "summary" }}Notes:
schedule.kind:at(atMs),every(everyMs), orcron(expr, optionaltz).atMsandeveryMsare epoch milliseconds.sessionTargetmust be"main"or"isolated"and must matchpayload.kind.- Optional fields:
agentId,description,enabled,deleteAfterRun,isolation. wakeModedefaults to"next-heartbeat"when omitted.
cron.update params
Section titled “cron.update params”{ "jobId": "job-123", "patch": { "enabled": false, "schedule": { "kind": "every", "everyMs": 3600000 } }}Notes:
jobIdis canonical;idis accepted for compatibility.- Use
agentId: nullin the patch to clear an agent binding.
cron.run and cron.remove params
Section titled “cron.run and cron.remove params”{ "jobId": "job-123", "mode": "force" }{ "jobId": "job-123" }Storage & history
Section titled “Storage & history”- Job store:
~/.kolo/cron/jobs.json(Gateway-managed JSON). - Run history:
~/.kolo/cron/runs/<jobId>.jsonl(JSONL, auto-pruned). - Override store path:
cron.storein config.
Configuration
Section titled “Configuration”{ cron: { enabled: true, // default true store: "~/.kolo/cron/jobs.json", maxConcurrentRuns: 1, // default 1 },}Disable cron entirely:
cron.enabled: false(config)KOLO_SKIP_CRON=1(env)
CLI quickstart
Section titled “CLI quickstart”One-shot reminder (UTC ISO, auto-delete after success):
kurahi cron add \ --name "Send reminder" \ --at "2026-01-12T18:00:00Z" \ --session main \ --system-event "Reminder: submit expense report." \ --wake now \ --delete-after-runOne-shot reminder (main session, wake immediately):
kurahi cron add \ --name "Calendar check" \ --at "20m" \ --session main \ --system-event "Next heartbeat: check calendar." \ --wake nowRecurring isolated job (deliver to WhatsApp):
kurahi cron add \ --name "Morning status" \ --cron "0 7 * * *" \ --tz "America/Los_Angeles" \ --session isolated \ --message "Summarize inbox + calendar for today." \ --deliver \ --channel whatsapp \ --to "+15551234567"Recurring isolated job (deliver to a Telegram topic):
kurahi cron add \ --name "Nightly summary (topic)" \ --cron "0 22 * * *" \ --tz "America/Los_Angeles" \ --session isolated \ --message "Summarize today; send to the nightly topic." \ --deliver \ --channel telegram \ --to "-1001234567890:topic:123"Isolated job with model and thinking override:
kurahi cron add \ --name "Deep analysis" \ --cron "0 6 * * 1" \ --tz "America/Los_Angeles" \ --session isolated \ --message "Weekly deep analysis of project progress." \ --model "opus" \ --thinking high \ --deliver \ --channel whatsapp \ --to "+15551234567"Agent selection (multi-agent setups):
# Pin a job to agent "ops" (falls back to default if that agent is missing)kurahi cron add --name "Ops sweep" --cron "0 6 * * *" --session isolated --message "Check ops queue" --agent ops
# Switch or clear the agent on an existing jobkurahi cron edit <jobId> --agent opskurahi cron edit <jobId> --clear-agentManual run (debug):
kurahi cron run <jobId> --forceEdit an existing job (patch fields):
kurahi cron edit <jobId> \ --message "Updated prompt" \ --model "opus" \ --thinking lowRun history:
kurahi cron runs --id <jobId> --limit 50Immediate system event without creating a job:
kurahi system event --mode now --text "Next heartbeat: check battery."Gateway API surface
Section titled “Gateway API surface”cron.list,cron.status,cron.add,cron.update,cron.removecron.run(force or due),cron.runsFor immediate system events without a job, usekurahi system event.
Troubleshooting
Section titled “Troubleshooting”“Nothing runs”
Section titled ““Nothing runs””- Check cron is enabled:
cron.enabledandKOLO_SKIP_CRON. - Check the Gateway is running continuously (cron runs inside the Gateway process).
- For
cronschedules: confirm timezone (--tz) vs the host timezone.
Telegram delivers to the wrong place
Section titled “Telegram delivers to the wrong place”- For forum topics, use
-100…:topic:<id>so it’s explicit and unambiguous. - If you see
telegram:...prefixes in logs or stored “last route” targets, that’s normal; cron delivery accepts them and still parses topic IDs correctly.