Architecture Overview
Legio follows a layered architecture with clear separation between domain logic and infrastructure. Dependencies flow strictly downward — data models at the bottom, infrastructure at the top.
System Topology
Three Layers
Code (Python Infrastructure)
The legio/ package — domain models, orchestration, Telegram integration. Behavior that cannot be expressed in prompts.
Blueprints (Templates)
The blueprints/ directory — default prompts and tool configs used when creating new centuriones. Committed to git, shared across deployments. Consumed once at centurio creation time.
Castra (Runtime State)
The castra/ directory — the live workspace. Centurio prompts (customized), edicta, acta, commentarii, and the praetorium database.
Module Map
Layer 0 — Foundation
| Module | Lines | Purpose |
|---|---|---|
errors.py | ~35 | LegioError hierarchy — 7 domain exception classes |
Layer 1 — Data Models
| Module | Lines | Purpose |
|---|---|---|
nuntius.py | ~50 | Immutable frozen dataclass with UUID4, sender, audience, timestamp |
centurio.py | ~80 | Agent identity, name validation (^[a-z][a-z0-9_-]*$), reserved name rejection, filesystem path properties |
config.py | ~120 | LegioConfig loaded from legio.toml + environment variables (frozen dataclass) |
Layer 2 — Storage
| Module | Lines | Purpose |
|---|---|---|
praetorium.py | ~170 | SQLite message bus with WAL mode, visibility rules, over-fetch heuristic |
rendering.py | ~115 | XML context formatting (format_history_xml), template rendering, attribution headers |
memoria/store.py | ~230 | Filesystem CRUD for edicta, acta, commentarii (XML format with name/symlink validation) |
auctoritas.py | ~130 | Pending TOTP request store — TTL, attempt tracking, auto-cleanup |
totp.py | ~60 | TOTP verification via pyotp — timing-safe comparison, drift tolerance |
Layer 3 — Orchestration
| Module | Lines | Purpose |
|---|---|---|
session.py | ~390 | SDK session lifecycle — create, dispatch, idle reap, token tracking, response collection |
memoria/tools.py | ~150 | MCP tool wrappers — full server (Legatus, 17 tools) and scoped server (centurio, 8 tools) |
Layer 4 — Coordinator
| Module | Lines | Purpose |
|---|---|---|
legatus.py | ~465 | Orchestrator — @mention parsing, centurio dispatch, LLM default response, MCP tool definitions (6 tools), roster/prompt change detection |
Layer 5 — Infrastructure
| Module | Lines | Purpose |
|---|---|---|
telegram/bot.py | ~100 | Application builder, handler registration (12 handlers), _caesar_filter |
telegram/commands.py | ~150 | Slash command handlers (/remove, /edict, /revoke, etc.), TOTP gating |
telegram/utils.py | ~245 | Reply helpers, typing indicators, live status, reply context extraction |
telegram/markdown_render.py | ~148 | Mistune v3 renderer for Telegram-compatible HTML (HTMLRenderer(escape=True)) |
Legatus: The Orchestrator
The Legatus class wears two hats:
- Orchestrator (code) — message parsing,
@mentionextraction, centurio dispatch - Default responder (SDK agent) — when no centurio is mentioned, responds using its own Claude SDK session
State
class Legatus:
_config: LegioConfig # Frozen config
_praetorium: Praetorium # Message bus
_memoria: MemoriaStore # Filesystem storage
_memoria_full: MemoriaServer # Full MCP server (17 tools)
_client: ClaudeSDKClient | None # Legatus's own SDK session
_client_is_fresh: bool # True until first query
_roster_hash: str # SHA-256 of centurio names + descriptions
_prompt_mtime: float # Last modified time of legatus/prompt.md
_session_mgr: CenturioSessionManager
_centuriones: dict[str, Centurio]Mention Extraction
The _MENTION_RE regex extracts @names from free-form text:
# SECURITY: negative lookbehind prevents matching @name inside emails/URLs
_MENTION_RE = re.compile(r"(?<![.\w/@])@(\w+)")Only names that match registered centuriones (case-insensitive) are returned. Unrecognized @mentions are ignored silently.
Client Rebuild Triggers
The Legatus SDK client is rebuilt when either condition is true:
| Trigger | Detection | Effect |
|---|---|---|
| Roster change | SHA-256 hash of name:description pairs | New system prompt with updated <centuriones> roster XML |
| Prompt edit | stat().st_mtime of legatus/prompt.md | New system prompt from edited file |
The check runs on every handle_message() call via _should_rebuild_client().
System Prompt Construction
The system prompt is built from the legatus prompt.md file plus an injected centurio roster:
[Contents of castra/legatus/prompt.md]
<centuriones>
<centurio name="vorenus">Research specialist for technology analysis</centurio>
<centurio name="brutus">Code review and security auditing</centurio>
</centuriones>Both names and descriptions are XML-escaped to prevent injection from prompt.md content.
Per-Query Status Injection
Real-time centurio status is injected into every user message (not the system prompt) so it stays fresh:
<centurio_status>
<centurio name="vorenus" status="idle"/>
<centurio name="brutus" status="working"/>
</centurio_status>
Caesar's actual message hereMCP Tools (Legatus)
The Legatus has 6 built-in tools plus all Memoria tools:
| Tool | Parameters | Purpose |
|---|---|---|
create_centurio | name, specialization | Create from blueprint template |
remove_centurio | name | Remove directory + disconnect session |
list_centuriones | — | Scan registry and return names/statuses |
dispatch_to_centurio | name, message | Send a nuntius to a specific centurio |
post_nuntius | text, audience | Post directly to the praetorium |
get_history | limit | Read recent message history |
Plus 11 Memoria tools (edicta, acta, commentarii CRUD) — total of 17 MCP tools.
History Bootstrap
On a fresh client (startup, rebuild), the first query prepends praetorium history as XML context:
<praetorium recent="true" viewer="legatus">
<nuntius id="uuid1" sender="caesar" timestamp="2025-01-15T10:30:00+00:00">
Research quantum computing advances
</nuntius>
<nuntius id="uuid2" sender="vorenus" timestamp="2025-01-15T10:31:00+00:00">
Based on my research, the latest advances include...
</nuntius>
</praetorium>
<context_notice>Session restored from praetorium. Ask Caesar for clarification if context is unclear.</context_notice>
<centurio_status>
<centurio name="vorenus" status="idle"/>
</centurio_status>
Caesar's new message hereAfter the first query, _client_is_fresh is cleared and subsequent messages only include status XML.
Message Flow
Free-Form Message (No @mentions)
@Mention Dispatch
Handle Message Flow
The handle_message() method is the main entry point:
- Scan —
scan_centuriones()refreshes the registry from filesystem - Parse —
extract_mentions(text)finds@namesmatching registered centuriones - Post — Caesar's nuntius posted to praetorium with correct
audience - Rebuild —
_ensure_legatus_client()checks roster hash + prompt mtime - Route — if
@mentions→ parallel dispatch; else → legatus LLM - Respond — post responses to praetorium with correct
sender - Attribute — prepend
⚔️ name — descriptionheader for centurio responses
Dependency Graph
No circular imports. Infrastructure never leaks into domain code. The domain layer has zero knowledge of Telegram.
Configuration
Settings come from two sources:
legio.toml (non-secrets)
[caesar]
telegram_id = 123456789
[legio]
model = "sonnet"
castra_dir = "castra"
max_centuriones = 10
history_window = 50
session_idle_timeout_minutes = 30Environment Variables (secrets)
TELEGRAM_BOT_TOKEN=... # Telegram Bot API token
ANTHROPIC_API_KEY=... # Claude API key
LEGIO_TOTP_SECRET=... # TOTP secret for destructive actionsLoaded once via load_config() into a frozen LegioConfig dataclass, then passed through constructors. No global state, no mutable singletons.