untether-architecture
Skill72 starsChanged 38 days agoMITView on GitHub
Untether overall architecture, event model, data flow, config system, engine backend registration, progress tracking, approval notifications, ephemeral cleanup, and key abstractions. Use when working on core infrastructure or understanding how the pieces fit together.
In this repository11 other files it gives its agents.
- AGENTS.mdAGENTS.md
- .codex/AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- .github/copilot-instructions.mdCopilot instructions
- llms-full.txtllms.txt
- llms.txtllms.txt
- claude-stream-jsonSkill
- codex-opencode-piSkill
- jsonl-subprocess-runnerSkill
- release-coordinationSkill
- telegram-bot-apiSkill
The file
We split this skill into the rules it gives an agent
2 rules. About 0 are common, 1 is distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
1
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 71
Every run emits: StartedEvent (once) -> ActionEvents (zero+) -> CompletedEvent (once).
- Distinctive: line 338
All Telegram writes go through the outbox
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Untether Architecture
Telegram bridge for agent CLIs (Claude Code, Codex, OpenCode, Pi). Control coding agents from anywhere.
Data flow
Telegram Bot API
|
v
TelegramClient (httpx, long polling)
|
v
telegram/loop.py (parse updates, dispatch commands/callbacks)
|
v
handle_message() in runner_bridge.py
|
v
Runner.run(prompt, resume) -> AsyncIterator[UntetherEvent]
| |
v v
ProgressEdits <-- on_event() -- StartedEvent / ActionEvent / CompletedEvent
|
v
TelegramPresenter.render_progress() / render_final()
|
v
TelegramOutbox (coalesced edits, rate-limited sends)
|
v
Telegram Bot APICore abstractions
Runner (Protocol)
class Runner(Protocol):
engine: str
def run(self, prompt: str, resume: ResumeToken | None) -> AsyncIterator[UntetherEvent]
def is_resume_line(self, line: str) -> bool
def format_resume(self, token: ResumeToken) -> str
def extract_resume(self, text: str | None) -> ResumeToken | NoneUntetherEvent (discriminated union)
type UntetherEvent = StartedEvent | ActionEvent | CompletedEvent | TurnEventEvery run emits: StartedEvent (once) -> ActionEvents (zero+) -> CompletedEvent (once). A Claude live session (#776) may follow it with TurnEvent(started) -> ActionEvent* -> TurnEvent(completed) segments, delivered by FollowupTurnRouter as separate Telegram messages.
RunnerBridge (runner_bridge.py)
Connects runners to the transport layer:
handle_message()— entry point for incoming Telegram messages- Creates
ProgressTrackerandProgressEdits - Spawns runner in a task group with cancel support
- Manages progress message lifecycle (create -> edit -> replace with final)
- Handles errors, cancellation, ephemeral cleanup
ProgressTracker (progress.py)
Aggregates events into renderable state:
tracker = ProgressTracker(engine="claude")
tracker.note_event(evt) # returns True if state changed
state = tracker.snapshot(
resume_formatter=runner.format_resume,
context_line="myproject@main",
)Snapshot includes: resume line, action list, action count, context line.
ProgressEdits
Live-updates the Telegram progress message:
- Signal-based: only renders when new events arrive
- Detects approval button transitions for push notifications
- Manages
_approval_notifiedflag and_approval_notify_ref delete_ephemeral()cleans up notification messages on run completion- Stall monitor reads the run's own
JsonlStreamState/PID from the per-runRunStreamHandle(ContextVar bound inrun_runner_with_cancel), never the sharedrunner.current_stream/last_pid(#510) - Expected waits (rate-limit
rejectedlatch #790,api_retryback-off #792, approvals) demote stall warnings; live-idle holds between turns raise no stall WARN and are reported aspeak_live_idle_secondsinsession.summary, notpeak_idle_seconds(#787) - Child processes earn the 15-min subagent threshold unconditionally only on Claude; on other engines only while the process tree uses CPU (Codex's npm shim and OpenCode's MCP servers are permanent children); a stopped engine (state
T) is reported as "Engine process is stopped" (#953) - The heartbeat repaints the header's elapsed time once nothing has repainted for a heartbeat interval (30 s), so silent generation still ticks (#954);
stop_repaints()must precede any final / cancelled edit so a debounced repaint can't land over it (#948) - Approval reminder (#919/#920): first at 10 min, then every 30 min; each repeat replaces the previous reminder (
_approval_reminder_ref) and it is deleted once the request is answered or the run ends edits.orphan_approvals(orphan_approvals.py, #929): a background agent'scan_use_toolthat arrives while the live session is idle gets its own pushed Approve / Deny message (same 10 / 30 min re-post, retired once answered). It never auto-denies; writes go through the transport only
TelegramPresenter (telegram/bridge.py)
Renders progress and final messages:
render_progress(state, elapsed_s, label)->RenderedMessagerender_final(state, elapsed_s, status, answer)->RenderedMessage- Inline keyboard buttons in
extra["reply_markup"]
RenderedMessage
@dataclass
class RenderedMessage:
text: str
extra: dict[str, Any] # reply_markup, parse_mode, followups, etc.Config system
untether.toml
# ~/.untether/untether.toml
default_engine = "claude"
default_project = "untether"
[transports.telegram]
bot_token = "..."
chat_id = -1001234567890
voice_transcription = true
session_mode = "chat"
topics.enabled = true
[claude]
model = "sonnet"
permission_mode = "plan"
[codex]
profile = "Codex"
[projects.untether]
path = "/home/nathan/untether"Settings hierarchy
UntetherSettings (pydantic-settings, TOML source)
├── TransportsSettings
│ └── TelegramTransportSettings
│ ├── TelegramTopicsSettings
│ └── TelegramFilesSettings
├── PluginsSettings
├── ProjectSettings (per project)
├── CostBudgetSettings, LoopSettings, FooterSettings, PreambleSettings,
│ ProgressSettings, WatchdogSettings, AutoContinueSettings, SecuritySettings
└── engine_config(engine_id) -> dict # [claude], [codex], etc.- Config loaded from
~/.untether/untether.toml - Engine configs in
[engine_id]sections (flat) or[engines.engine_id](nested) - Environment overrides:
UNTETHER__prefix with__nesting
ChatPrefsStore
Per-chat persistent preferences: default_engine, listen mode, context project/branch,
followup_mode (#775) and a per-engine engine_overrides: dict[str, EngineOverrides]:
class EngineOverrides(msgspec.Struct): # telegram/engine_overrides.py
model: str | None = None
reasoning: str | None = None
permission_mode: str | None = None
ask_questions: bool | None = None
diff_preview: bool | None = None
# ... footer / budget / loop toggles- Stored in
telegram_chat_prefs_state.json(topic-level overrides in the topic state) - Set via
/agent,/model,/reasoning,/planmode,/configcommands - Applied at run time to override global config
Engine backend registration
Entry points (pyproject.toml)
[project.entry-points."untether.engine_backends"]
codex = "untether.runners.codex:BACKEND"
claude = "untether.runners.claude:BACKEND"
opencode = "untether.runners.opencode:BACKEND"
pi = "untether.runners.pi:BACKEND"
gemini = "untether.runners.gemini:BACKEND" # deprecated, unsupported
amp = "untether.runners.amp:BACKEND" # deprecated, unsupportedEngineBackend
@dataclass(frozen=True, slots=True)
class EngineBackend:
id: str
build_runner: Callable[[EngineConfig, Path], Runner]
cli_cmd: str | None = None
install_cmd: str | None = NoneDiscovery: importlib.metadata.entry_points(group="untether.engine_backends")
Command system
Command handlers (telegram/commands/)
| File | Commands |
dispatch.py | Callback dispatch, early answering, ephemeral registration |
claude_control.py | Approve/Deny/Discuss handlers, outline-gate wiring |
planmode.py | /planmode toggle |
usage.py | /usage — Claude subscription quota; token totals for other engines (#417) |
model.py | /model override |
reasoning.py | /reasoning override |
listen.py | /listen (formerly /trigger, still accepted) — all-messages vs mentions-only |
agent.py | /agent — engine selection |
CommandResult
@dataclass
class CommandResult:
text: str
notify: bool = True
reply_to: MessageRef | None = None
parse_mode: str | None = None # "HTML" for bold formatting
skip_reply: bool = False
attachment: CommandAttachment | None = None # #418: sent as a documentCommands return CommandResult; dispatch sends it as a Telegram message.
Callback dispatch
Callback data format: <prefix>:<action>:<id> (max 64 bytes).
claude_control:approve:<request_id>— approve control requestclaude_control:deny:<request_id>— deny control requestclaude_control:discuss:<request_id>— pause & outline planclaude_control:chat:<request_id>— let's discuss (holds the request open)- synthetic post-outline buttons carry a
da:<session_id>request id; AskUserQuestion options useaq:opt:<i>/aq:other - opt-in
[transports.telegram] approval_originator_only(#388):claude_control:/aq:taps (and typed AskUserQuestion answers) are accepted only from the user whose message started the run; cron / webhook //at/ loop runs have no originator, so any allowed user may answer
Running tasks
RunningTasks = dict[MessageRef, RunningTask]
@dataclass
class RunningTask:
resume: ResumeToken | None
resume_ready: anyio.Event
cancel_requested: anyio.Event
done: anyio.Event
context: RunContext | None
edits: ProgressEdits | None # #690: drain self-restart evidence scan
thread_id: ThreadId | None # #826: scopes /new and /cancel to a topic- Keyed by progress message ref
/cancelsetscancel_requestedeventdoneevent signals run completion for cleanup
Project system
Projects bind a directory + optional branch to a Telegram context:
[projects.untether]
path = "/home/nathan/untether"
default_engine = "claude"
chat_id = -1001234567890 # optional per-project chat/topic <project> @branchcreates bound topics/ctx set <project>binds a chat context- Project alias used as directive prefix:
/untether fix the bug
Trigger system
Triggers let external events or schedules start agent runs automatically. Opt-in via [triggers] enabled = true.
Cron
run_cron_scheduler() ticks every minute, checking each [[triggers.crons]] entry against the current time via cron_matches() (5-field standard syntax). Per-cron timezone or global default_timezone converts UTC to local wall-clock time via _resolve_now() + zoneinfo.ZoneInfo. DST transitions handled automatically. last_fired dict prevents double-firing within the same minute.
Webhooks
run_webhook_server() runs an aiohttp server. Each [[triggers.webhooks]] maps a URL path to auth (bearer/HMAC-SHA256/SHA1) + prompt template with {{field.path}} substitutions. Rate-limited per-webhook and globally.
Dispatch
Both crons and webhooks feed into TriggerDispatcher.dispatch_cron()/dispatch_webhook() → sends a notification message to Telegram (⏰/⚡) → calls run_job() with the prompt, threading under the notification.
- Cron/webhook runs are unattended (
RunContext.trigger_source): a Claude approval, plan approval or question is denied at once (permission.unattended_deny, #835) — they need an explicit autonomouspermission_mode(plan-auto,auto,dontAsk). A reply to the run continues attended./atruns still ask. - Per-cron
permission_mode/model/reasoningoverrides apply to that run only (_apply_trigger_overrides).
Loop mode (loop_scheduler.py)
Untether runs /loop schedules itself and, on control-channel Claude spawns, owns Claude's CronCreate /
CronDelete through PreToolUse hook callbacks (#925): Loop on → registered as an Untether loop under the [loop]
caps; Loop off → denied with Loop mode / /at guidance. [loop] own_schedule = false is the kill switch. Detail:
.claude/skills/claude-stream-json/control-channel-internals.md → "Scheduling hooks".
Key files
triggers/cron.py— cron parser, timezone-aware schedulertriggers/settings.py—CronConfig,WebhookConfig,TriggersSettings(pydantic)triggers/dispatcher.py— notification +run_job()bridgetriggers/server.py— aiohttp webhook servertriggers/auth.py— bearer/HMAC verificationtriggers/templating.py—{{field.path}}prompt substitution
Key conventions
- Python 3.12+, anyio for async, msgspec for JSONL parsing, structlog for logging
- pydantic + pydantic-settings for config validation
- Ruff for linting, pytest with coverage for tests
- Runner backends registered via entry points
- All Telegram writes go through the outbox
- Exactly one CompletedEvent per run (enforced by JsonlStreamState)
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

