Core Concepts
Legio uses Roman military terminology as its domain vocabulary. Each Latin term maps to a precise technical concept that avoids collision with generic programming terms like "agent", "message", or "session".
Agents
Caesar
The human operator. Caesar communicates exclusively through Telegram and has god-view access to the entire system — all messages, all memoria, all centuriones. Caesar's identity is verified by Telegram user ID configured in legio.toml.
Legatus
The orchestrator agent. Legatus wears two hats:
- Orchestrator (code) — parses
@mentionsvia regex(?<![.\w/@])@(\w+), routes messages, manages centurio lifecycle - Default responder (SDK agent) — when no centurio is mentioned, Legatus responds using its own
ClaudeSDKClientsession
The Legatus has MCP tools for creating/removing centuriones, dispatching messages, and accessing the full memoria system.
Legatus MCP Tools
| Tool | Parameters | Description |
|---|---|---|
create_centurio | name: str, specialization: str | Create a centurio from blueprint template |
remove_centurio | name: str | Remove a centurio and disconnect its session |
list_centuriones | — | List all registered centuriones with status and description |
dispatch_to_centurio | name: str, message: str | Send a message to a specific centurio |
post_nuntius | text: str, audience: str | Post a nuntius to the praetorium (comma-separated audience) |
get_history | limit: int | Retrieve recent praetorium history as XML |
| + All memoria tools | — | Full edicta, acta, and commentarii access |
Legatus Rebuild Triggers
The Legatus SDK session is rebuilt when:
- Roster hash changes — a centurio was added, removed, or had its description edited (SHA-256 of
name:descriptionpairs) - Prompt mtime changes —
castra/legatus/prompt.mdwas modified on disk
On rebuild, the _client_is_fresh flag is set, triggering praetorium history bootstrap on the next query.
Centurio (pl. Centuriones)
A specialist agent. Each centurio is defined by:
prompt.md— system prompt describing its role and capabilitiestools.json— MCP tool configurationcommentarii/— private notes directory (append-only)
Centuriones are created from blueprints (templates) and live in castra/centuriones/<name>/. They are identified by lowercase names validated against a strict pattern (^[a-z][a-z0-9_-]*$).
Reserved names (caesar, legatus, all, praetorium) are blocked to prevent identity spoofing.
Centurio Data Model
@dataclass
class Centurio:
name: str # Validated in __post_init__
centuria: Path # Path to castra/centuriones/<name>/
status: str = "idle" # "idle" | "working" | "error"
@property
def prompt_path(self) -> Path # centuria / "prompt.md"
@property
def tools_path(self) -> Path # centuria / "tools.json"
@property
def commentarii_dir(self) -> Path # centuria / "commentarii"Centurio MCP Tools
Each centurio gets a scoped MCP server (memoria_<name>) with restricted access:
| Tool | Parameters | Notes |
|---|---|---|
list_edicta | — | Read-only (no publish/revoke) |
read_edictum | name: str | Read-only |
list_acta | — | Full access |
read_actum | name: str | Full access |
publish_actum | name: str, content: str | Author auto-set to centurio name |
list_commentarii | — | Auto-scoped to owning centurio |
read_commentarium | name: str | Auto-scoped to owning centurio |
write_commentarium | name: str, content: str | Auto-scoped to owning centurio |
Messages
Nuntius (pl. Nuntii)
The immutable message type. Every communication in the system — from Caesar's Telegram messages to centurio responses — is a Nuntius.
@dataclass(frozen=True)
class Nuntius:
sender: str # "caesar", "legatus", or centurio name
text: str # message body
audience: tuple[str, ...] = ("all",) # explicit recipients
id: str # UUID4 (auto-generated)
timestamp: datetime # UTC timezone-aware (auto-generated)
reply_to: str | None = None # threading via UUID referenceKey properties:
- Frozen — once created, never modified. Prevents mutation bugs in concurrent dispatch.
- Audience-based visibility — centuriones only see nuntii addressed to them or to
"all" - Caesar and Legatus see everything (god-view)
- Threading —
reply_tolinks responses to the originating message via UUID
Visibility Rules
Praetorium
The SQLite-backed message bus that stores all nuntii. Think of it as the shared communication log that gives agents conversational context.
CREATE TABLE nuntii (
id TEXT PRIMARY KEY, -- UUID4
sender TEXT NOT NULL, -- "caesar", "legatus", or centurio name
text TEXT NOT NULL, -- message body
audience TEXT NOT NULL, -- JSON array: ["all"] or ["vorenus", "brutus"]
timestamp TEXT NOT NULL, -- ISO 8601 UTC
reply_to TEXT, -- UUID of parent nuntius
FOREIGN KEY (reply_to) REFERENCES nuntii(id)
);
CREATE INDEX idx_nuntii_timestamp ON nuntii(timestamp);
CREATE INDEX idx_nuntii_sender ON nuntii(sender);Praetorium API
| Method | Returns | Description |
|---|---|---|
open() | — | Open database, create schema, set WAL + foreign keys |
close() | — | Close the database connection |
post(nuntius) | — | Insert a nuntius into the database |
get_visible_nuntii(viewer, limit=50) | list[Nuntius] | Return nuntii visible to viewer, newest first |
get_history(limit=50) | list[Nuntius] | Return last N nuntii regardless of audience (admin view) |
Features:
- WAL mode for concurrent read/write performance
- Visibility rules enforced at application level (no SQL
LIKE— exact match filtering) - History injection — agents receive relevant past messages as XML context
- Deduplication — subsequent dispatches only inject new messages since the last injection
- Over-fetch heuristic — fetches
limit * 5rows when filtering for centuriones to compensate for interleaved audiences
Memory (Memoria)
Three layers of persistent storage, all filesystem-based with XML format:
Edicta (Standing Orders)
Global directives that all agents should read before starting any task. Mutable — can be published, updated, and revoked.
<edictum name="policy" author="caesar" timestamp="2026-02-15T12:00:00+00:00">
Always respond in formal English.
</edictum>Example: "Always respond in formal English" or "Prefer TypeScript for code examples".
Acta (Shared Knowledge)
Published outputs available to all agents. Mutable — any agent can publish or update an actum.
<actum name="findings" author="vorenus" timestamp="2026-02-15T12:00:00+00:00">
Research findings on quantum computing approaches.
</actum>Example: a research centurio publishes findings that a writing centurio later uses.
Commentarii (Private Notes)
Per-centurio append-only journal. Only the owning centurio can read and write its own commentarii. The Legatus can read any centurio's commentarii (admin privilege).
<commentarium name="notes" timestamp="2026-02-15T12:00:00+00:00">
Intermediate results from the analysis task.
</commentarium>Example: a centurio keeps scratch notes, intermediate results, or task state across sessions.
MCP Tool Access Matrix
| Tool | Centurio | Legatus |
|---|---|---|
list_edicta | ✅ read | ✅ read |
read_edictum | ✅ read | ✅ read |
publish_edictum | ❌ | ✅ write |
revoke_edictum | ❌ | ✅ write |
list_acta | ✅ read | ✅ read |
read_actum | ✅ read | ✅ read |
publish_actum | ✅ write | ✅ write |
list_commentarii | 🔒 own only | ✅ all |
read_commentarium | 🔒 own only | ✅ all |
write_commentarium | 🔒 own only | ✅ all |
Workspace
Castra
The runtime workspace directory tree:
castra/
legatus/
prompt.md # Legatus system prompt
centuriones/
vorenus/
prompt.md # Centurio-specific prompt
tools.json # MCP tool config
commentarii/ # Private notes (gitignored)
edicta/ # Standing orders (tracked)
acta/ # Shared knowledge (tracked)
praetorium.db # Message history (gitignored)Blueprints
Template directory (blueprints/) used when creating new centuriones. Contains prompt.md.template and tools.json.template with and placeholders. Committed to git, shared across deployments.
On first run, ensure_castra() copies blueprints/legatus/prompt.md.template to castra/legatus/prompt.md.
Error Hierarchy
All domain errors inherit from a single base class, caught at Telegram boundaries:
Rules:
- Domain code raises domain exceptions, never stdlib (
KeyError,FileNotFoundError) - Catch at boundaries (Telegram handler), propagate in domain code
- Never swallow exceptions — log at DEBUG and re-raise
- Generic error messages at Telegram boundary (
❌ An error occurred) prevent information disclosure
Authorization (Auctoritas)
Pending authorization requests for destructive actions like centurio removal or edictum revocation.
@dataclass
class Auctoritas:
id: str # UUID4
action: str # "remove_centurio" or "revoke_edictum"
payload: dict # {"name": "vorenus"}
chat_id: int # Telegram chat where request was made
user_id: int # Telegram user who initiated the request
message_id: int # Telegram message ID of OTP prompt (for cleanup)
expires_at: datetime # TTL = 120 seconds (hardcoded)
attempts: int # Max 3 attempts (hardcoded)The AuctoritasStore is keyed by (chat_id, user_id) to prevent group-chat bypasses. Expired or exhausted requests are automatically cleaned up on the next get_pending() call.
Security Model
Authentication
- Caesar is verified by Telegram user ID (configured in
legio.toml) - Only the verified Caesar can interact with the bot
- All non-Caesar messages are silently dropped at framework level
- Destructive actions (removing a centurio, revoking an edictum) require TOTP authorization
Name Validation
Every centurio and memoria entry name is validated:
| Target | Regex | Reserved Names |
|---|---|---|
| Centurio | ^[a-z][a-z0-9_-]*$ | caesar, legatus, all, praetorium |
| Memoria | ^[a-z0-9][a-z0-9_-]*$ | — |
This prevents path traversal (../../etc/passwd), identity spoofing, and filesystem safety issues.
XML Escaping
All text injected into XML context blocks is escaped via xml.sax.saxutils.escape() and quoteattr(). This prevents prompt injection through crafted message text.
Symlink Rejection
Every filesystem operation checks path.is_symlink() and rejects symlinks to prevent symlink-follow attacks.
Memoria Safety
- Path traversal protection at every filesystem boundary
- Commentarii are append-only — no overwrite, no delete
- XML content always wrapped with escaped attributes