Summary
Explore which parts of Untether can be dynamically updated via config reload (watch_config) without requiring a full bot restart. Currently, editing untether.toml and restarting the service is the only way to apply changes to several subsystems that could reasonably support hot-reload.
Motivation
Adding a new cron trigger, adjusting watchdog timeouts, or tweaking progress settings currently requires systemctl --user restart untether-dev — which kills all active runs. For a Telegram-first workflow, being able to edit config and have it apply live is a significant UX improvement.
Current state
Already hot-reloadable (no work needed)
- Projects config — chat-to-project mappings update on config change
- Per-chat preferences — engine, plan mode, verbose, etc. (JSON state files, reloaded on-demand)
- Preamble — re-read from TOML on every run via
_load_preamble_settings()
- Per-chat/topic engine overrides — model, reasoning (persistent JSON, on-demand reload)
- Command menu —
handle_reload() already calls refresh_commands() + set_command_menu()
Needs work
| Area |
Current behaviour |
What hot-reload would look like |
Effort |
| Triggers (cron + webhooks) |
Parsed once at startup in loop.py:1488-1525; run_cron_scheduler() loops forever with a fixed crons list |
on_reload callback cancels old scheduler/webhook tasks, spins up new ones with fresh config; preserve last_fired state to avoid double-firing |
Medium-hard |
| Watchdog settings |
WatchdogSettings read at startup, baked into runner bridge |
Read tool_timeout, mcp_tool_timeout from current settings per-run instead of caching |
Easy |
| Progress settings |
ProgressSettings (verbosity, max_actions, min_render_interval) read at startup |
Fetch from runtime on each ProgressEdits construction or per-render cycle |
Easy |
| Footer settings |
FooterSettings read at startup |
Fetch per-render from runtime |
Easy |
| Cost/budget settings |
CostSettings read at startup |
Re-read thresholds per-run from runtime; active CostTracker picks up new limits |
Easy-medium |
Out of scope (entry-point limitation)
- Engine registration — Python entry points require package reinstall
- Command registration — same entry-point constraint
- These would need a fundamentally different plugin discovery mechanism
Implementation sketch
Triggers (the big one)
- Wrap cron scheduler + webhook server tasks in a
TriggerManager that holds CancelScope references
- On
on_reload, if trigger config changed: cancel old scopes, start new scheduler/server tasks
- Carry forward
last_fired dict to avoid re-firing within the same minute
- For webhooks: graceful close of old HTTP listener before binding new one (or skip if routes unchanged)
Settings per-run
For watchdog, progress, footer, and cost settings:
- Store the
TransportRuntime reference (already available in runner bridge)
- Read settings from
runtime.settings at the point of use rather than caching at init
- Minimal code change — mostly replacing
self._timeout with self._runtime.settings.watchdog.tool_timeout
Acceptance criteria
Related
config_watch.py — existing file watcher infrastructure
handle_reload() in telegram/loop.py:1272 — existing reload callback (currently handles projects + command menu)
watch_config: bool = False in settings.py:198 — opt-in flag
Summary
Explore which parts of Untether can be dynamically updated via config reload (
watch_config) without requiring a full bot restart. Currently, editinguntether.tomland restarting the service is the only way to apply changes to several subsystems that could reasonably support hot-reload.Motivation
Adding a new cron trigger, adjusting watchdog timeouts, or tweaking progress settings currently requires
systemctl --user restart untether-dev— which kills all active runs. For a Telegram-first workflow, being able to edit config and have it apply live is a significant UX improvement.Current state
Already hot-reloadable (no work needed)
_load_preamble_settings()handle_reload()already callsrefresh_commands()+set_command_menu()Needs work
loop.py:1488-1525;run_cron_scheduler()loops forever with a fixedcronsliston_reloadcallback cancels old scheduler/webhook tasks, spins up new ones with fresh config; preservelast_firedstate to avoid double-firingWatchdogSettingsread at startup, baked into runner bridgetool_timeout,mcp_tool_timeoutfrom current settings per-run instead of cachingProgressSettings(verbosity, max_actions, min_render_interval) read at startupProgressEditsconstruction or per-render cycleFooterSettingsread at startupCostSettingsread at startupCostTrackerpicks up new limitsOut of scope (entry-point limitation)
Implementation sketch
Triggers (the big one)
TriggerManagerthat holdsCancelScopereferenceson_reload, if trigger config changed: cancel old scopes, start new scheduler/server taskslast_fireddict to avoid re-firing within the same minuteSettings per-run
For watchdog, progress, footer, and cost settings:
TransportRuntimereference (already available in runner bridge)runtime.settingsat the point of use rather than caching at initself._timeoutwithself._runtime.settings.watchdog.tool_timeoutAcceptance criteria
[triggers]inuntether.tomlwithwatch_config = trueapplies new cron/webhook config without restart[watchdog]timeouts applies to the next run without restart[progress]settings applies to the next progress render without restart[footer]settings applies to the next completed run without restart[cost]budget thresholds applies to the next run without restartRelated
config_watch.py— existing file watcher infrastructurehandle_reload()intelegram/loop.py:1272— existing reload callback (currently handles projects + command menu)watch_config: bool = Falsein settings.py:198 — opt-in flag