Skip to main content
Guides Last updated: 8 October 2026

Untether Architecture & Lifecycle

Nathan Schram
By Founder, Little Bear Apps

Layer Diagram

CLI Layer Plugin Layer Orchestration Layer Bridge Layer Runner Layer Transport Layer Triggers Layer External cli/ Entry point, config loading, lock file plugins.pyentrypoint discovery engines.py transports.py commands.py api.pypublic plugin API AutoRouterrouter.py ThreadSchedulerscheduler.py ProjectsConfigconfig.py TransportRuntimetransport_runtime.py telegram/loop.pyrun_main_loop runner_bridge.pyhandle_message Runner Protocolrunner.py runners/claude, codex, opencode, pi,gemini + amp (deprecated) schemas/JSONL decoders Transport Protocol Presenter Protocol telegram/client.py telegram/render.py markdown.py triggers/server.pywebhook HTTP servermultipart, rate limit triggers/cron.pycron schedulertimezone, run_once triggers/manager.pyTriggerManagerhot-reload + pause/resume triggers/dispatcher.pydispatch to run_job triggers/actions.pyfile_write, http_forward, notify_only triggers/fetch.pycron data-fetch triggers/ssrf.pySSRF protection Agent CLIsclaude, codex, opencode, pi, gemini, amp Telegram Bot API Webhook SourcesGitHub, CI, etc.

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

ResumeToken + EngineId: engine: + str: value: Action + str: id: + ActionKind: kind: + str: title: + dict: detail: StartedEvent + "started": type: + EngineId: engine: + ResumeToken: resume: + str?: title: ActionEvent + "action": type: + EngineId: engine: + Action: action: + started|updated|completed: phase: + bool?: ok: + str?: message: CompletedEvent + "completed": type: + EngineId: engine: + bool: ok: + str: answer: + ResumeToken?: resume: + dict?: usage:

Message Lifecycle

alt [Command plugin] [Default routing] loop [JSONL Stream] Send message poll_incoming() Parse slash command handle(ctx) run_one/run_many (optional) Send progress/final Parse directives(/<engine-id>, /<project-alias>, @branch) Extract resume tokenfrom reply Resolve worktree(if @branch) enqueue(ThreadJob) handle_message() Send progress message run(prompt, resume) Spawn subprocess JSONL event UntetherEvent Edit progress message Completed CompletedEvent Send final answer Delete progress message User Telegram telegram/loop.py ThreadScheduler runner_bridge.py Runner Agent CLI Command Plugin

Runner Execution Flow

Claude Codex Pi OpenCode 1.x Gemini, deprecated Amp, deprecated EOF runner.runprompt, resume_token Acquire Session LockSessionLockMixin Build Command Engine? "claude --output-format stream-json--input-format stream-json --verbose[--resume id "codex exec --json[resume <token> pi --print --mode json--session <id> <prompt> "opencode run --format json[--session id "gemini --output-format stream-json[--resume id amp --stream-json-x <prompt> Spawn Subprocessanyio.open_process Stream JSONL from stdout Decode with msgspec Translate to UntetherEvent yield event Return

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).

prompt run(prompt) spawn + prompt on stdin result (turn 1) final answer task_notification → new turn TurnEvent (🔔 / 📡 / ⏰) wake-turn message follow-up live_followup writes to stdin(after the current turn ends) result (follow-up turn) close stdin (graceful exit) background task, Monitor orScheduleWakeup still running idle ~60 s with no background work,or background hold / 4 h cap reached User runner_bridge.py ClaudeRunner claude (live)
  • Turns. Each later turn arrives as a TurnEvent segment and is delivered as its own Telegram message. Background work is tracked from Claude Code’s own system/task_* events; the background status message (background_status.py) is built from the same task map.
  • Follow-ups. ThreadScheduler still 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 at live_session_max_s (4 h). /cancel, /new, settings changes (including a /ctx change, 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 unexpectedly notice (#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

fix the bug run(prompt, None) claude "fix the bug" StartedEvent(resume=abc123) Stream events Final message with:claude --resume abc123dir: project @branch Reply: "now add tests" extract_resume(reply_text)→ ResumeToken(claude, abc123) parse_context_line()→ project, branch run("now add tests", token) claude --resume abc123 "now add tests" Continues session Stream events Final message Resume Conversation User Bridge Runner Agent CLI

Component Dependencies

runners/ schemas/ cli/ config.py engines.py transports.py commands.py lockfile.py plugins.py backends.py runner.py router.py tg_bridge transport_runtime.py runner_bridge.py telegram/client.py telegram/render.py transport.py progress.py events.py presenter.py markdown.py claude.py codex.py opencode.py pi.py gemini.py amp.py claude.py codex.py opencode.py pi.py gemini.py amp.py

Configuration Structure

~/.untether/ untether.toml untether.toml untether.lock state files (JSON)chat prefs, sessions, topics,active_progress, last_update_id,active_loops, daily_cost, stats,session_costs, session_quarantine,run_once_fired / run_once_pending,triggers_history transportdefault_enginedefault_projectwatch_config "[transports.telegram "[plugins "[plugins.mycommand "[engines.claude "[engines.codex "[progress "[triggers "[projects.alias

Thread Scheduling

Incoming Messages ThreadScheduler Worker Tasks Runner.run Runner.run Runner.run Jobs in same threadexecute sequentially Different threadsexecute in parallel Message 1new thread Message 2reply to thread A Message 3reply to thread A Message 4new thread Thread A Queue Thread B Queue Thread C Queue Worker A Worker B Worker C

Summary

LayerComponentsResponsibility
CLIcli/Entry point, config, lock
Pluginsplugins.py, engines.py, transports.py, commands.py, api.pyEntrypoint discovery, plugin loading, public API boundary
Orchestrationrouter.py, scheduler.py, config.pyEngine selection, job queuing, project config
Bridgetelegram/loop.py, telegram/bridge.py, runner_bridge.pyMessage handling, execution coordination
Runnerrunner.py, runners/*.py, schemas/*.pyAgent CLI subprocess, JSONL parsing, event translation
Transporttransport.py, presenter.py, telegram/client.pyTelegram API, message rendering
Triggerstriggers/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.pyWebhook 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
Domainmodel.py, progress.py, events.pyEvent types, action tracking
Utilsworktrees.py, utils/*.py, markdown.pyGit worktrees, formatting, paths
Was this helpful?

Related Articles