Untether Architecture & Lifecycle
- Engine backends (untether.engine_backends) - Transport backends (untether.transport_backends) - Command backends (untether.command_backends)
Layer Diagram
Plugin Architecture
Untether discovers plugins via Python entrypoints and keeps loading lazy:
- Engine backends (
untether.engine_backends) - Transport backends (
untether.transport_backends) - Command backends (
untether.command_backends)
Entrypoint names become plugin IDs, are validated up front (reserved names, regex),
and are only loaded when needed. The public surface for plugin authors lives in
untether.api, while transports and commands interact with core routing via
TransportRuntime.
Domain Model
Message Lifecycle
Runner Execution Flow
Live sessions (Claude Code)
The flow above ends at the first result. Since v0.36.0 a Claude Code run in a permission mode (the control-channel mode, without -p) is a live session: Untether keeps reading after the answer, so the process stays open while Claude Code has background work in flight (#776).
- Turns. Each later turn arrives as a
TurnEventsegment and is delivered as its own Telegram message. Background work is tracked from Claude Code’s ownsystem/task_*events; the background status message (background_status.py) is built from the same task map. - Follow-ups.
ThreadSchedulerstill serialises jobs per thread, but a queued follow-up for a live session is written into the running process (live_followup.py) instead of waiting for it to exit and--resume-ing. A steered message is written straight away and read at the next tool boundary. - Closing. The session closes by closing stdin: about a minute after the last turn when nothing is running, after
[watchdog] post_result_bg_max_hold(30 min) with no background activity, or atlive_session_max_s(4 h)./cancel,/new, settings changes (including a/ctxchange, so the next message resumes in the new directory, #996) and restarts close it too, with a notice. A clean close is not quarantined, so the next message resumes the same session. A process killed from outside Untether while idle gets its own⚠️ … ended unexpectedlynotice (#1001). - Per-run state. Runner instances are shared across chats, so each run publishes its own stream and PID through a per-run handle rather than shared runner attributes (#510).
[watchdog] live_sessions = false restores the stop-at-first-answer behaviour. Other engines are not affected. See the Claude runner reference for the protocol details.
Resume Token Flow
Component Dependencies
Configuration Structure
Thread Scheduling
Summary
| Layer | Components | Responsibility |
|---|---|---|
| CLI | cli/ | Entry point, config, lock |
| Plugins | plugins.py, engines.py, transports.py, commands.py, api.py | Entrypoint discovery, plugin loading, public API boundary |
| Orchestration | router.py, scheduler.py, config.py | Engine selection, job queuing, project config |
| Bridge | telegram/loop.py, telegram/bridge.py, runner_bridge.py | Message handling, execution coordination |
| Runner | runner.py, runners/*.py, schemas/*.py | Agent CLI subprocess, JSONL parsing, event translation |
| Transport | transport.py, presenter.py, telegram/client.py | Telegram API, message rendering |
| Triggers | triggers/server.py, triggers/cron.py, triggers/manager.py, triggers/dispatcher.py, triggers/actions.py, triggers/fetch.py, triggers/ssrf.py, triggers/auth.py, triggers/rate_limit.py, triggers/describe.py, triggers/history.py, triggers/templating.py | Webhook server (multipart, rate limit, 503 triggers paused), cron scheduler (timezone, data-fetch, run_once), TriggerManager for hot-reload + master pause/resume toggle, fire-history persistence for /stats triggered/manual breakdown, non-agent actions (file_write/http_forward/notify_only), SSRF protection, HMAC/bearer auth, human-friendly cron description |
| Domain | model.py, progress.py, events.py | Event types, action tracking |
| Utils | worktrees.py, utils/*.py, markdown.py | Git worktrees, formatting, paths |