Architecture
A high-level view of how Agenvoy fits together. For per-module diagrams, sequence flows, and the tool-dispatch state machine, jump into the topic-specific pages.
Overview
graph TB
subgraph Entry["Entry · cmd/app"]
App["agen · TUI + daemon<br/>agen stop · update · --daemon<br/>non-TTY stdin · MCP server"]
Web["Web dashboard · page/<br/>embedded in the binary<br/>served at 127.0.0.1:17989/"]
end
subgraph Engine["Engine · internal/agents/exec"]
Run["exec.Prepare · exec.Start<br/>skill match → named skill → ResolveAgent<br/>session model · dispatcher · tiers · priority"]
Execute["exec.Execute · ≤128 iterations<br/>3-pass parallel tool calls"]
Sub["ExecWithSubagent<br/>in-process · ≤3 concurrent"]
Compact["compact · threshold → tool history<br/>→ old history → trim fallback"]
end
subgraph Pending["Pending · internal/runtime"]
Reg["Prefix-routed listener registry<br/>per-entry buffered=1 reply ch<br/>TUI cli- · web chat- · Telegram tg- · Discord dc-"]
end
subgraph Providers["go-llm-router v0.6.0 · 13 provider entries + compat"]
P["OpenAI · Codex · Claude · Gemini<br/>Grok · Grok (xAI) · Copilot · DeepSeek<br/>Mistral · NVIDIA · Ollama Cloud · OpenRouter<br/>Cloudflare · + any OpenAI-compatible endpoint"]
end
subgraph Tools["Tool Subsystem · internal/tools"]
T["find_files · read_files · edit_file · file_history<br/>search_web · fetch_page · http_request<br/>subagents · schedules · run_skill · ask_user<br/>chat_history · error_history · find_note<br/>reasoning_guide · write_report"]
MCP["MCP · internal/runtime/mcp<br/>client + server · go-sdk"]
Adapter["toolAdapter · internal/runtime<br/>script_* · api_* · ext_*"]
end
subgraph Security["Security · go-pkg/sandbox"]
S["bwrap (Linux) / sandbox-exec (macOS)<br/>filesystem.Policy · DeniedMap"]
end
subgraph Session["Session · internal/session"]
SL["history.json · summary.json<br/>action.log · pending/<br/>fsnotify watch"]
end
subgraph Memory["Memory · SQLite + ToriiDB"]
M["history.db · session · message_meta · note<br/>messages + FTS5 · action_history<br/>file_history · usage<br/>db_0..db_3 · error_memory 90d TTL"]
end
subgraph Kura["KuraDB · user-registered MCP server"]
K["kura mcp<br/>one entry in mcp.json<br/>mcp__kura__list_rag · mcp__kura__search_rag"]
end
App --> Run
Web --> Run
Run --> Execute
Execute -->|subagents| Sub
Sub --> Execute
Execute --> Compact
Execute -->|Send| Providers
Execute -->|tool calls| Tools
Tools --> MCP
Tools --> Adapter
Tools --> Security
Tools -.->|mcp__kura__*<br/>absent when the server is not registered| Kura
Execute <-->|confirm/ask| Pending
Sub <-->|subagent ask_user| Pending
Execute <--> Memory
Execute <--> Session
Layers
| Layer | Package | Responsibility |
|---|---|---|
| Entry | cmd/app |
argv dispatch (stop / update / --daemon); MCP server on non-TTY stdin; init sandbox, filesystem policy, MCP manager |
| App services | internal/app |
services shared by daemon and TUI: agent registry build, config.json watcher that reloads the registry and Telegram / Discord, session watcher, MCP setup, chat push hooks, skill runs, daemon spawn |
| Startup | internal/startup |
start-on-login via a launchd plist (macOS) or systemd service (Linux), toggled from /config; moved from internal/runtime/startup, which no longer exists |
| Notes | internal/note |
operator notes in the SQLite note table with an FTS5 trigram index; the old file and ToriiDB copies are migrated in at startup |
| Runtime singleton | internal/runtime |
server-mode UID lock; SIGTERM prior server on startup |
| Engine | internal/agents/exec |
Prepare / Start (skill match, named skill, model resolution); iteration loop; tool dispatch; dispatcher routing with tiers and priority (selectAgent.go); cooldown and retry (retryHandler); subagent host (execWithSubagent.go, at most 3 concurrent) |
| Compaction | internal/agents/exec/compact |
per-model threshold, tool-history and old-history folding, trim and raw-tool fallbacks |
| Providers | go-llm-router v0.6.0 (external module) |
unified Agent.Send() across 13 provider entries plus any OpenAI-compatible endpoint; reasoning and fast-mode normalization. Speech-to-text and text-to-speech route separately, and only OpenAI and Gemini back them |
| Tools | internal/tools + internal/runtime/toolAdapter |
built-in / API / script / extension tool definitions |
| MCP | internal/runtime/mcp |
client and server on the official modelcontextprotocol/go-sdk, one package |
| Sandbox | go-pkg/sandbox |
OS-native isolation, single entry Wrap() |
| Filesystem | go-pkg/filesystem (+ reader/) + internal/filesystem |
policy-aware writes; ToriiDB pathing |
| Session | internal/session |
history.json / summary / action.log / pending metadata / fsnotify observer; session config lives in the SQLite session table, and liveness is a ToriiDB online marker rather than a persisted state row |
| Dashboard | page/ + internal/runtime/routes |
Web UI embedded into the binary and served at /; AGENVOY_PAGE_DIR serves it from disk instead. On startup the daemon also installs a Chrome app launcher pointed at that URL — a bundle under ~/Applications on macOS, a .desktop entry on Linux, a Windows shortcut under WSL — so the dashboard opens as its own window. It is written once and skipped thereafter; a missing Chrome only logs a warning. Since v1.0.1 it also runs offline: /sw.js precaches the embedded assets in a service worker and /vendor/* serves the third-party libraries from ~/.config/agenvoy/vendor/, filled once at daemon startup by internal/runtime/webapp.SyncAsset and refreshed when the binary version changes |
| Pending | internal/runtime/pending.go |
prefix-routed confirm/ask listener registry; per-front-end listener via RegisterListener(prefix), claim via PickNext(prefix) / PickNextMatch(prefix, accept) (PickNextFor was removed) |
| Memory | go-sqlkit (SQLite: session, message_meta, note + FTS5, messages + FTS5, action_history, file_history, usage) + ToriiDB (db_0 tool and provider-quota cache, db_1 chat vectors, db_2 error memory, db_3 online markers) |
full-text archive (trigram tokenizer), operator notes, per-model usage, file snapshots, semantic search, 90-day error memory. The state table was dropped in v0.35.3, when liveness moved to ToriiDB online markers; ToriiDB is reached over its own socket since v0.35.3 (now ToriiDB v0.7.0), so the /v1/toriidb HTTP gate is gone |
| Scheduler | internal/runtime scheduler + fsnotify watcher |
cron / one-shot tasks bound to scheduler skills; hot-reload on {tasks,crons}.json change |
| TUI | internal/runtime/tui |
bubbletea inline-chat front-end; single-package by design |
Cross-cutting principles
- OS-native sandbox over Go-side filters — security policy is enforced at the OS boundary; new restrictions go into
go-pkg/sandbox, not into agenvoy callers - Prompt as policy — permission mode, sensitive operations, and system-prompt protection live in
configs/prompts/; adding a category means editing the prompt, not the engine - In-process over HTTP for subagents —
subagents(mode=invoke)callsexec.Executedirectly, sharing the same provider clients, sandbox, pending registry, and memory layer;AllowAllandWorkDirflow through ctx - Read tools fan out, write tools serialize — concurrency is opt-in and requires both "no side effects" and "upstream allows parallelism"
- One config layer per concern — provider credentials in OS keychain, registered models, priority, tiers, and limits in
config.json, MCP inmcp.json, session config and persona in the SQLitesessiontable; each tool author / user touches at most one place - One HTTP surface — the TUI, the browser dashboard, the chat channels, and external callers all go through the same daemon on
127.0.0.1:17989; the dashboard is embedded rather than hosted, so there is no second origin to keep in sync - Vendor code lives upstream — provider adapters moved to
go-llm-routerand the MCP protocol to the official go-sdk; a fix belongs in the upstream module rather than a local shim
TUI design choices
Per pardn chiu: "bubbletea isn't designed to be split into separate modules that reference each other — splitting it would make the lifecycle a mess. I don't have the bandwidth to handle it right now." This module is intentionally kept undivided.
The TUI lives in a single package (internal/runtime/tui) and is not split into subpackages. Every file under internal/runtime/tui/ follows this principle.
Why bubbletea (not tview / tcell)
The previous TUI used rivo/tview. It was replaced because:
- Inline scrollback: bubbletea's
tea.Printlnwrites lines that scroll into the terminal's native buffer above the input box. tview owns the entire screen and can't co-exist with shell scrollback. - lipgloss styling primitives: borders, padding, foreground/background composition compose cleanly. tview styles are tag-based and harder to reuse across components.
- bubbles ecosystem:
textarea,spinner,cursorare drop-in components that match the rest of the charm-bracelet style.
The cost is that bubbletea is a Go port of The Elm Architecture — its tea.Model interface is monolithic by design.
Why a single package
tea.Model requires Update(tea.Msg) (tea.Model, tea.Cmd) to be a method on the model type. Methods must live in the same package as the type. This forces:
- All
Updatelogic in the same package as the model - Splitting into subpackages requires a wrapper in a third (root) package, plus exporting every model field so the sub-packages can read/write state
- Currently
unexportedtypes likepopupState,commandPickerState,viewModewould have to become exported, creating an "API" that no one outsideinternal/runtime/tuiwill ever consume send()andprogram atomic.Pointer[tea.Program]either move into a sub-package (root sets via setter API) or stay in root and force handlers to import root, which creates a second cycle
A real Go-style TUI would build per-domain widget packages (each owning its state struct, render method, and event handler) with bubbletea acting only as event loop. That refactor is a 600-800 LOC rewrite split into 4 phases. For the current TUI (about 10.7k non-test LOC across internal/runtime/tui) maintained by one developer, the gain doesn't justify the cost.
When to revisit
Switch to per-domain widget packages when any one of:
- TUI exceeds ~3k LOC and code review keeps stalling on "where does this belong"
- Multiple developers regularly touch the TUI and step on each other's state
- Specific widgets need independent unit tests against frozen state — currently impossible without instantiating the whole
Model
[!NOTE] This document was auto-generated by Claude after reading the full source code.