Schedule tasks
There are several ways to run tasks on a schedule: the /at command for quick one-shot delays, Telegram's built-in message scheduling…
There are several ways to run tasks on a schedule: the /at command for quick one-shot delays, Telegram’s built-in message scheduling, Untether’s trigger system (webhooks and cron), and Loop mode for Claude Code’s /loop and ScheduleWakeup.
Loop mode is opt-in
With Loop mode off (the default), Claude can’t create recurring or timed tasks of its own: Untether declines Claude Code’s
CronCreateand Claude tells you to turn on Loop mode, or to use/atfor a one-off delay. Self-paced waits (ScheduleWakeup, a dynamic/loop) still work while the session stays open after its reply, and arrive as⏰ Scheduled wake-upmessages; a chain of them stops after[loop] max_iterationswake-ups. For repeated runs on a schedule, turn on Loop mode in/config → 🔁 Loop mode. See Loop mode below.
One-shot delays with /at
The /at command schedules a prompt to run after a delay — useful for reminders, follow-ups, or “run this in 30 minutes”:
/at 30m Check the build
/at 2h Review the PR feedback
/at 60s Say hello
Duration format: Ns (seconds), Nm (minutes), or Nh (hours). Minimum 60 seconds, maximum 24 hours.
After scheduling, you’ll see a confirmation:
Untether
⏳ Scheduled: will run in 30m Cancel with /cancel.
When the delay expires, the prompt runs as a normal agent session. Send /cancel (not as a reply) to cancel all pending delays in the current chat, or in a forum topic that topic’s; it replies with what it dropped (❌ cancelled 2 pending /at runs.). If a run is in progress, that /cancel stops the run instead, so send a second one to drop the delays. A Claude session that is only idling after its answer doesn’t count as a run: /cancel closes it and still drops the delays (#902).
Not persistent
Pending
/atdelays are held in memory. They are lost if Untether restarts. For persistent scheduled tasks, use cron triggers instead.
Engine and project frozen at schedule time
When you run
/at, Untether snapshots the engine and project that a plain message in that chat (or forum topic) would use at that moment: the topic’s or chat’s/agentdefault first, then the default of the project bound with/ctx(or the chat’s project), then the global default (#950). That snapshot is what fires when the delay expires — changing/agent,/ctx, or/planmodeafterwards does not affect already-scheduled delays. Cancel with/canceland re-schedule if you change your mind. (#362)
Loop mode
Claude Code has a built-in /loop <interval> <prompt> command (and a no-interval /loop <prompt> dynamic mode driven by ScheduleWakeup) for self-pacing autonomous work. With Loop mode on, Untether runs those schedules itself, with limits, and fires each iteration when due — whether or not a Claude session is still open. (#289, #925)
Default OFF — opt-in per chat via /config → 🔁 Loop mode. With Loop mode off, Claude can’t schedule recurring or timed tasks itself: it tells you that scheduling is off and points you to Loop mode, or to /at <delay> <prompt> for a one-off. Before v0.36.0 such a task ran uncapped while the session was open and came back each time the session was resumed.
How it works
- You type
/loop 5m check the deployin a Claude session. - Claude calls
CronCreate(cron="*/5 * * * *", prompt="check the deploy", recurring=true). - Untether declines the call before it runs (a Claude Code PreToolUse hook) and registers the loop on its own timer, with the caps below. Claude is told the loop’s id and that Untether runs it, so nothing is scheduled inside Claude Code and nothing comes back when the session is resumed.
- Each fire interval, Untether runs
claude --resume <session_id>with a wrapped re-issue prompt:Loop iteration N: check the deploy. Do the task now; do not summarize old results unless necessary.If the session is still open and idle, Untether closes it first; if it’s busy (background work, an approval waiting), the iteration waits, and is skipped if the next one falls due first. - A self-paced
ScheduleWakeuplonger than 5 minutes (inline_threshold_seconds) fires inside the open session. Untether fires it only if the session has closed by then. - State persists to
active_loops.json(sibling ofuntether.toml) — loops survive Untether restarts.
To switch this off, set [loop] own_schedule = false: Untether then only observes Claude’s schedule (the behaviour before v0.36.0), and a Claude-side job can fire uncapped while the session is open.
Runaway-safety caps
Every loop has caps in case it runs longer than expected (configurable under [loop]):
- 20 iterations (
max_iterations) — cap on iteration count (NOT a cost cap) - 4 hours (
max_total_duration_hours) — wall-clock cap (NOT a cost cap) - 7 days (
expiry_days) — auto-expire 7 days after creation (matches upstream)
These bound loop duration regardless of cost. They are not a substitute for setting a budget — see “Cost considerations” below.
Self-paced wake-ups
A chain of self-paced wake-ups (ScheduleWakeup, a dynamic /loop) stops after max_iterations consecutive wake-ups, in both Loop modes: the session closes with a ⏰ Self-paced wake-up limit reached notice and the pending wake-up is dropped. Your next message resets the count. The count is per Claude process, so a resumed session starts again from zero.
Cost considerations
Autonomous loops consume API credits or your Claude subscription quota. A 24-hour /loop 1m can fire up to 1440 times. Cost per fire depends on conversation length:
- Short conversations: ~$0.01–$0.05 per fire (cache-warm).
- Long conversations: cache may evict between fires, costing $0.10–$0.50 per fire.
Set a daily budget BEFORE turning on Loop mode in /config → 💰 Cost & usage (or [cost_budget].max_cost_per_day in untether.toml). The same daily cost cap applies to loop fires automatically — there is no separate per-loop budget. With Stop at limit on, fires after the cap is reached are refused. See Cost budgets for setup.
Stopping a loop
/cancel drops all active loops for the current chat or forum topic (#826), and your next message resumes the session without the cancelled loop coming back. New loops created in that session afterwards run normally (#926). /new does the same (treats /new as “wipe this chat’s — or this topic’s — state”). You can also ask Claude to stop it: Claude calls CronDelete with the loop’s id (ut_loop_…). In a forum, a loop belongs to the topic whose run created it, and its iterations are posted back in that topic.
With Loop mode off there is nothing to stop: Claude can’t schedule on its own, and a self-paced wait ends with /cancel or /new.
A session that may still hold an older Claude-side scheduled task (one created before v0.36.0, in a chat without a permission mode, or with own_schedule = false) resumes with Claude’s scheduling switched off for up to 7 days, so the old task can’t restart; the progress message says so. /new starts a session that can schedule again. A legacy task in a Loop-off session can still fire up to twice after the upgrade (the first time before your reply) before Untether catches it.
Telegram scheduling
Telegram’s native message scheduling works with Untether out of the box.
In Telegram, long-press the send button and choose Schedule Message to run tasks at a specific time. You can also set up recurring schedules (daily/weekly) for automated workflows.
This is the simplest approach — no server or config changes needed.
How to schedule
In Telegram, long-press the send button (iOS) or tap the clock icon (Android/Desktop) and choose Schedule Message. Pick a date and time, then tap Send. Untether receives the message at the scheduled time and starts the run automatically.
Cron triggers
For more control, use Untether’s built-in cron system. Cron triggers fire on a schedule and start agent runs automatically.
toml
[triggers]
enabled = true
[[triggers.crons]]
id = "daily-review"
schedule = "0 9 * * 1-5"
project = "myapp"
chat_id = -1001234567890 # the chat to post in — usually myapp's chat
engine = "claude"
prompt = "Review open PRs and summarise their status."
This runs every weekday at 9:00 AM (server time) in the myapp project using
Claude Code, and posts in chat -1001234567890. Add
timezone = "Australia/Melbourne" to evaluate in a specific timezone, or set
default_timezone in [triggers] for all crons. See
Webhooks and cron for details.
projectdoesn’t choose the chat
projectsets the working directory only. Withoutchat_id, the cron posts to the defaultchat_idfrom[transports.telegram], not the chat bound tomyapp. Untether logstrigger.cron.chat_fallbackwhen that happens and the two chats differ (#894).
Turning triggers on needs one restart
Most trigger edits hot-reload, but switching
[triggers] enabledfromfalsetotrueonly takes effect after a restart, because the cron scheduler starts at startup. The Telegram reload notice says Restart required when this applies.
Common schedules:
| Expression | Meaning |
|---|---|
0 9 * * * | Daily at 9:00 AM |
0 9 * * 1-5 | Weekdays at 9:00 AM |
*/30 * * * * | Every 30 minutes |
0 */4 * * * | Every 4 hours |
Add run_once = true to fire a cron exactly once, then auto-disable. Fired state persists to run_once_fired.json (sibling of your untether.toml), so a reload or restart will not re-fire it. Remove the cron from your TOML to clean up.
Autonomous crons in plan-mode chats (Claude)
By default a cron inherits the chat’s permission mode, and if the chat has none, the engine default: [engines.claude] permission_mode, or, if that’s unset, Claude Code’s non-interactive -p path, with no Telegram approvals or plan mode. Nobody is around to approve anything when a cron fires, so since v0.36.0 an unattended Claude run denies anything that would wait for a tap: a plan approval, a question, or a tool the mode would ask about (#835). A cron that inherits plan therefore ends with a plan instead of doing the work.
Always set an explicit permission_mode on a Claude cron that should act unattended. Set permission_mode = "auto" (or "bypassPermissions") to make that run autonomous without flipping the whole chat:
[[triggers.crons]]
id = "overnight-review"
schedule = "0 6 * * *"
chat_id = -1001234567890
engine = "claude"
prompt = "Review overnight PRs and reply with a summary."
permission_mode = "auto"
autochanged meaning in v0.36.0Before v0.36.0,
permission_mode = "auto"meant plan mode with the plan gate auto-approved. It now selects Claude Code’s own classifier-gated auto mode, which has no plan phase. Existing crons keep running but behave differently — set"plan-auto"to restore the previous behaviour. Untether logs one warning at startup, and again if a config reload changes the list, naming every engine setting and cron that uses"auto".
Unattended runs never wait for a tap
A cron or webhook run has nobody to tap Approve, so Untether denies, at once, anything that would wait for one, and tells Claude to carry on without it or stop and report what it would have done (#835). The progress message shows
🔒 Unattended run — denied <tool>: nobody to approve it, the final lists every denial once (🔒 unattended (cron:<id>) · denied Write ×2 — nobody to approve), and each one logspermission.unattended_deny. What that means per mode:
default,manual,acceptEdits: any tool the mode would ask about is denied.plan: the plan approval is denied (the run ends with the plan as its answer), and so areEdit,Write,MultiEdit,NotebookEditandBash. Useplan-autoif the cron should plan and then act.auto,dontAsk,bypassPermissions: routine work runs as before. Only requests the CLI still asks the host about are denied — anaskrule, a hook that answersask, a tool that needs a person, a critical-pathrm, and auto mode falling back to asking after repeated classifier blocks.- With diff preview on in the chat, a file edit that would have shown its diff for approval is denied too.
Questions (
AskUserQuestion) are denied in every mode, with a note to proceed on reasonable defaults./atruns are not affected: you scheduled them from the chat, so their buttons work as usual. A reply to an unattended run’s message continues in an attended session with normal buttons.Untether also logs
trigger.unattended_approval_riskat startup for crons set todefault,manual,acceptEditsorplan, and when a cron or webhook fires into a chat whose mode would ask (outcome=denied). The Telegram startup message lists such Claude crons on one line —unattended approvals (auto-denied): cron:nightly (plan), cron:digest (inherits plan) +2 more— including crons with nopermission_modewhen the engine default (planout of the box) would ask (#836). Set the mode on each listed cron to clear it. The line is built once at startup (a reload doesn’t refresh it), skips spentrun_oncecrons, and doesn’t list webhooks, which always take their chat’s mode.
Precedence (Claude): cron permission_mode > per-chat /planmode > engine config default. Every autonomous run logs trigger.cron.permission_mode_override. Valid values: default (alias manual), plan, plan-auto, auto, acceptEdits, dontAsk, bypassPermissions. For a Codex cron, "safe" selects Codex’s read-only sandbox for that run; the deprecated Gemini engine passes the value through as --approval-mode, and OpenCode, Pi and AMP ignore the field (#332 tracks full coverage).
Pick a model per cron
A cron normally runs on the chat’s /model and reasoning setting. Give it its own with model and reasoning (effort, for Claude) — they apply to that scheduled run only, so routine jobs in an Opus chat can run on a cheaper model without changing the chat for interactive use (#743):
[[triggers.crons]]
id = "nightly-triage"
schedule = "0 6 * * *"
chat_id = -1001234567890
engine = "claude"
model = "sonnet"
reasoning = "low"
permission_mode = "auto"
prompt = "Triage new issues and reply with a summary."
[[triggers.crons]]
id = "weekly-deep-review"
schedule = "0 9 * * 1"
chat_id = -1001234567890
engine = "claude"
model = "opus"
reasoning = "high"
permission_mode = "plan-auto"
prompt = "Review last week's merged PRs for design problems."
- Precedence: cron
model/reasoning> the topic’s or chat’s/modeland reasoning > engine config ([engines.claude] model) > the CLI default. Unset inherits, as before. - Set
enginetoo, so the model name matches the engine that runs it (a Claude alias likesonnetfails on Codex). modelis free-form, like/model set; an unknown name fails the run with the engine’s own error. It can’t start with-or contain spaces.reasoningmust be a level the engine accepts — Claudelow,medium,high,xhigh,max; Codexlow…xhigh— checked at config load whenengineis set. OpenCode and Pi don’t take it. Some models don’t support every effort level (Claude Code says the levels available depend on the model), so pairmaxwith a model that has it.- A reply to the cron’s message runs on the chat’s model, not the cron’s; if the cron’s session is still open for background work it is closed and resumed with the chat’s settings.
- The footer and the
runner.completedlog show the model that actually ran;trigger.cron.model_override/trigger.cron.reasoning_overrideare logged when the cron’s value differs from the chat’s, and/config → ⏰ Triggersshowsmodel=/effort=on that cron’s row. - Webhooks can’t pick a model yet (#332).
Trigger provenance and history
Trigger-initiated runs are visibly distinct from manual ones — every run footer carries a provenance marker:
⏰ cron:<id>— fired by a cron trigger⚡ webhook:<id>— fired by a webhook trigger⏰ at:<token>— fired by/at
/stats reports a per-engine (N triggered, M manual) breakdown next to each engine line and on the totals row when at least one count is nonzero (#271 Tier 3).
/config → ⏰ Triggers (config:tg) lists every cron and webhook configured for the current chat — for crons: describe_cron(schedule, timezone), project, engine, last-fired relative time; for webhooks: path, auth scheme, project, engine, last-fired. Lists are scoped to the current chat, capped at 10 entries with a …and N more (see untether.toml) overflow marker. The page also hosts the master pause/resume toggle (see below). See Inline settings for the navigation walkthrough.
Last-fired times are persisted to triggers_history.json (sibling of untether.toml) so the values survive a restart. Renaming a trigger ID in TOML leaves a stale entry that operators can manually delete (no auto-prune to avoid losing data on transient TOML errors).
Pausing all triggers
When you need to silence the bot for maintenance, demos, or a noisy upstream, the master pause toggle suspends all cron firing and webhook dispatch globally without changing your config (#294).
- From
/config: open⏰ Triggers(or use the one-button toggle row on the home page when triggers are configured) and tap Pause. - While paused: the cron scheduler skips its tick (
run_oncecrons are not consumed during the pause and fire on the next matching tick after resume); the webhook server returns503 triggers pausedwithRetry-After: 60instead of dispatching;/healthreports{"status":"paused","paused":true}for external monitors;/pingshows⏸ triggers paused: … (suspended). - Restart auto-resumes — pause is in-memory only by design; restarting the bot is a safe escape hatch.
Tap Resume in the same page to clear the pause.
Webhook triggers
Webhooks let external services (GitHub, Slack, PagerDuty) trigger agent runs via HTTP POST.
toml
[triggers]
enabled = true
[[triggers.webhooks]]
id = "github-push"
path = "/hooks/github"
auth = "hmac-sha256"
secret = "whsec_abc..."
event_filter = "push"
project = "myapp"
prompt_template = "Review push to {{ref}} by {{pusher.name}}"
See Webhooks and cron for the full setup guide, including authentication, prompt templating, and testing.
Related
- Webhooks and cron — full trigger setup guide with examples
- Triggers reference — complete configuration reference