Skip to content

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:

  1. Orchestrator (code) — parses @mentions via regex (?<![.\w/@])@(\w+), routes messages, manages centurio lifecycle
  2. Default responder (SDK agent) — when no centurio is mentioned, Legatus responds using its own ClaudeSDKClient session

The Legatus has MCP tools for creating/removing centuriones, dispatching messages, and accessing the full memoria system.

Legatus MCP Tools ​

ToolParametersDescription
create_centurioname: str, specialization: strCreate a centurio from blueprint template
remove_centurioname: strRemove a centurio and disconnect its session
list_centuriones—List all registered centuriones with status and description
dispatch_to_centurioname: str, message: strSend a message to a specific centurio
post_nuntiustext: str, audience: strPost a nuntius to the praetorium (comma-separated audience)
get_historylimit: intRetrieve 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:description pairs)
  • Prompt mtime changes — castra/legatus/prompt.md was 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 capabilities
  • tools.json — MCP tool configuration
  • commentarii/ — 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 ​

python
@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:

ToolParametersNotes
list_edicta—Read-only (no publish/revoke)
read_edictumname: strRead-only
list_acta—Full access
read_actumname: strFull access
publish_actumname: str, content: strAuthor auto-set to centurio name
list_commentarii—Auto-scoped to owning centurio
read_commentariumname: strAuto-scoped to owning centurio
write_commentariumname: str, content: strAuto-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.

python
@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 reference

Key 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_to links 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.

sql
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 ​

MethodReturnsDescription
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 * 5 rows 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.

xml
<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.

xml
<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).

xml
<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 ​

ToolCenturioLegatus
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.

python
@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:

TargetRegexReserved 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.

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

Built with Roman discipline.