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 cli / run · single-shot<br/>agen stop · update · --daemon<br/>non-TTY stdin · MCP server"]
end
subgraph Engine["Engine · internal/agents/exec"]
Run["exec.Run<br/>:name → skill → model: hint"]
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 listen '' · Telegram listens 'tg-'"]
end
subgraph Providers["go-llm-router · 12 providers"]
P["OpenAI · Codex · Claude · Gemini<br/>Grok · Copilot · DeepSeek · NVIDIA<br/>OpenRouter · Cloudflare · Compat"]
end
subgraph Tools["Tool Subsystem · internal/tools"]
T["File · Web · HTTP · Media · Memory<br/>run_skill · invoke_subagent · ask_user<br/>scheduler · revision · reasoning_guide"]
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["bot.md · status.json · action.log<br/>usage.log · fsnotify watch"]
end
subgraph Memory["Memory · ToriiDB + SQLite"]
M["DBSessionHist · DBSessionSummary<br/>error_memory · 90d TTL<br/>FTS5 archive · text-embedding-3-small"]
end
subgraph Kura["KuraDB · daemon-managed child"]
K["/usr/local/bin/kura<br/>endpoint @ ~/.config/kuradb/endpoint<br/>list_rag · search_rag"]
end
App --> Run
Run --> Execute
Execute -->|invoke_subagent| Sub
Sub --> Execute
Execute --> Compact
Execute -->|Send| Providers
Execute -->|tool calls| Tools
Tools --> MCP
Tools --> Adapter
Tools --> Security
Tools -.->|list_rag · search_rag<br/>per-turn excluded if endpoint absent| Kura
Execute <-->|confirm/ask| Pending
Sub <-->|subagent ask_user| Pending
Execute <--> Memory
Execute <--> Session
App -.->|spawn + healthcheck| Kura
Layers
| Layer | Package | Responsibility |
|---|---|---|
| Entry | cmd/app |
argv dispatch (cli / run / stop / update / --daemon); MCP server on non-TTY stdin; init sandbox, filesystem policy, MCP manager |
| Runtime singleton | internal/runtime |
server-mode UID lock; SIGTERM prior server on startup |
| Engine | internal/agents/exec |
iteration loop; tool dispatch; provider routing; subagent host (execWithSubagent.go) |
| Compaction | internal/agents/exec/compact |
per-model threshold, tool-history and old-history folding, trim and raw-tool fallbacks |
| Providers | go-llm-router (external module) |
unified Agent.Send() across 12 providers; reasoning and fast-mode normalization |
| 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 |
bot.md / status.json / action.log / fsnotify observer |
| Pending | internal/runtime/pending.go |
prefix-routed confirm/ask listener registry; per-runtime listener via RegisterListener(prefix), claim via PickNextFor(prefix) |
| Memory | ToriiDB (DBSessionHist / DBSessionSummary / error_memory) + go-sqlkit (SQLite FTS5 archive) |
semantic search + 90-day TTL + full-text persistent archive |
| Scheduler | internal/runtime scheduler + fsnotify watcher |
cron / one-shot tasks bound to scheduler skills; hot-reload on {tasks,crons}.json change |
| KuraDB | internal/runtime/kuradb/ (kuradb.go / run.go) + internal/runtime/kuradb/tool/ |
RAG provider child process; daemon-managed spawn + 3-strike health check; per-turn dynamic tool exclusion when endpoint missing |
| 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 —
invoke_subagentcallsexec.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 and limits in
config.json, MCP inmcp.json, persona inbot.md; each tool author / user touches at most one file - 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 ~1.1k LOC 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.