Architecture
This document covers muxd's internal architecture for contributors.
Project Structure
muxd uses internal/ sub-packages organized by domain. main.go is pure wiring: all business logic lives in packages.
| Package | Purpose | Key exports |
|---|---|---|
main.go | Entry point, flag parsing, wiring | |
domain | Shared types (zero internal deps) | ContentBlock, TranscriptMessage, Session, NewUUID, CommandDef |
config | Configuration and preferences | ConfigDir, DataDir, LoadAPIKey, Preferences, LoadPricing, TelegramConfig |
store | SQLite persistence | Store, OpenStore |
provider | LLM provider abstraction | Provider, AnthropicProvider, OpenAIProvider, ResolveModel, ModelCost |
tools | 18 built-in agent tools | ToolDef, ToolContext, AllTools |
agent | Agent loop (adapter-independent) | Service, Event, CompactMessages |
checkpoint | Git undo/redo | DetectGitRepo, StashCreate |
daemon | HTTP server, client, lockfile | Server, DaemonClient, WriteLockfile, ReadLockfile |
service | OS service management | HandleCommand (install/uninstall/start/stop) |
tui | Bubble Tea TUI | Model, InitialModel, Update, View, RenderAssistantLines |
telegram | Telegram bot adapter | NewAdapter, Run, MarkdownToTelegramHTML |
Dependency Graph (no cycles)
domain ← leaf, no internal imports
↑
config ← imports domain
↑
store ← imports domain, config
↑
provider ← imports domain
↑
tools ← imports domain, config, provider
↑
checkpoint ← imports domain (just git operations)
↑
agent ← imports domain, provider, tools, checkpoint
↑
daemon ← imports domain, store, agent, config, provider
↑
service ← imports config, daemon
↑
tui ← imports domain, store, config, provider, daemon, checkpoint
↑
telegram ← imports domain, store, config, provider, agent, checkpoint
↑
main ← imports all
Bubble Tea Model
muxd's TUI is built with Bubble Tea. The core pattern:
tui.Modelstruct: single source of truth for all UI state (messages, input buffer, streaming state, etc.).Update(msg): dispatches on message types. Keys are handled byhandleKey(), slash commands byhandleSlashCommand(), stream events by dedicated handlers.View(): pure render function, no side effects. Renders the input prompt, status footer, completion menu, and any in-progress streaming content.tui.Prog.Println(): pushes finalized content into native terminal scrollback. The activeView()area only shows the current input and in-progress streaming.
Custom message types are defined at the top of tui/model.go:
StreamDeltaMsg: a text chunk from the streaming responseStreamDoneMsg: stream completed (carries token counts, stop reason)ToolStatusMsg: tool started executing on the serverToolResultMsg: tool finished executingTurnDoneMsg: full agent turn complete (server-driven)AskUserMsg: agent's ask_user tool needs user inputPasteMsg: clipboard paste resultClipboardWriteMsg: clipboard copy result
Client/Server Architecture
muxd runs in two modes:
-
TUI mode (default): Starts an embedded HTTP server, creates a
DaemonClientto talk to it, and runs the Bubble Tea TUI. The TUI communicates with the agent loop exclusively through HTTP/SSE. -
Daemon mode (
--daemon): Starts the HTTP server headlessly on port 4096. Multiple TUI clients can connect to the same daemon via lockfile discovery.
Lockfile Discovery
When the TUI starts, it checks ~/.local/share/muxd/server.lock for an existing daemon. If found and healthy (PID alive + HTTP health check passes), it connects. Otherwise it starts an embedded server.
SSE Event Flow
The daemon streams agent events to clients via Server-Sent Events:
TUI → POST /api/sessions/{id}/submit → daemon
daemon → agent.Service.Submit() → runs agent loop
agent loop → emits agent.Event callbacks → daemon converts to SSE
SSE events → DaemonClient.Submit() parses → sends tea.Msg to TUI
Agent Loop
The agent.Service (in internal/agent/) handles multi-turn tool use independently of any UI:
Submit(userText)
│
▼
Persist user message
│
▼
Compact if needed (>150k tokens)
│
▼
provider.StreamMessage() Stream API call
│
▼
Emit EventDelta callbacks Text chunks
│
▼
EventStreamDone stop_reason?
│
┌┴──────┐
│ │
end_turn tool_use
│ │
▼ ▼
Done Create checkpoint (if git available)
│
▼
Execute tools (parallel, or sequential for ask_user/plan/task)
│
▼
Emit EventToolStart / EventToolDone
│
▼
Persist tool results
│
▼
Loop back (max 25 iterations)
Key details:
- Tools are executed in parallel by default, sequentially when
ask_user,plan_enter,plan_exit, ortaskis present. - The agent loop is capped at 25 iterations to prevent runaway behavior.
- Cancellation via
Cancel()stops at the next safe point. - Checkpoints are created before each tool-use turn.
- Plan mode: When active, write tools (
file_write,file_edit,bash,patch_apply) are excluded from the tool list sent to the provider. The agent can only read and search. - Sub-agents: The
tasktool spawns a freshagent.Servicewith no store (no persistence), no git, andisSubAgent=true. Sub-agents have all tools excepttask(prevents recursion). They run synchronously and return their output. - ToolContext: Each tool execution receives a
*ToolContextproviding access to shared agent state: the working directory, the todo list, plan mode flag, and the sub-agent spawn callback.
Streaming
muxd supports both Anthropic and OpenAI streaming:
Anthropic SSE Streaming
- POST request with
stream: trueto/v1/messages. - Line-by-line parsing with
bufio.Scanner. - Events:
content_block_start,content_block_delta,content_block_stop,message_delta,message_stop. - Text deltas emitted via
EventDeltacallback. - Tool use blocks accumulate JSON incrementally, parsed on
content_block_stop.
OpenAI SSE Streaming
- POST request with
stream: trueto/v1/chat/completions. - Same line-by-line SSE parsing.
- Handles
tool_callsin delta format, accumulating JSON arguments.
Progressive Flush (TUI)
During streaming, long responses are progressively flushed to terminal scrollback:
- Text deltas accumulate in
streamBuf. flushStreamContent()checks for safe flush points (paragraph boundaries).FindSafeFlushPoint()verifies the point isn't inside a code fence.- Safe content is rendered and pushed to scrollback via
tui.Prog.Println().
Session Persistence
SQLite Schema
sessions table:
id(UUID),project_path,title,modeltotal_tokens,input_tokens,output_tokens,message_countcreated_at,updated_at
messages table:
id(UUID),session_id(FK),role,content,content_typetokens,created_at,sequence
content_type is either text (plain string) or blocks (JSON array of content blocks, used for tool_use/tool_result messages).
The database uses WAL mode for concurrent read performance and has foreign keys enabled. Schema migrations run on startup with IF NOT EXISTS guards and ALTER TABLE ADD COLUMN with ignored errors for forward compatibility.
Auto-titling
After the first assistant response, the session title is set to the first user message, truncated to 50 characters.
Context Compaction
When input token count exceeds 150,000 tokens, the conversation is compacted:
- Keep the first user + assistant exchange (the "head").
- Keep the last 20 messages (the "tail"), starting on a user message to maintain proper alternation.
- Serialize the dropped middle messages and call the LLM to generate a structured summary (topics, files modified, tools used, key decisions, current task state).
- Replace the placeholder notice with the real summary. On LLM error, falls back to
"[N earlier messages were compacted. No summary available.]".
The summary is persisted via SaveCompaction so resumed sessions restore the LLM-generated context. The compactIfNeeded method handles this flow and runs automatically before each API call in the agent loop.
OS Service Management
The internal/service/ package supports installing muxd as a system service:
| Platform | Mechanism |
|---|---|
| macOS | launchd plist in ~/Library/LaunchAgents/ |
| Linux | systemd user unit in ~/.config/systemd/user/ |
| Windows | Registry run key in HKCU\...\Run |
Commands: muxd -service install|uninstall|status|start|stop