Morgana

Morgana

A magical witch assistant equipped with an enchanted grimoire powered by AI, yet shaped by you: your agents, your prompts, your tools…woven into her spells

HANDBOOK
.NET 10 Akka.NET Microsoft.Agents.AI OpenTelemetry

Index

Architecture Overview

Morgana is a conversational AI framework built on a multi-agent, intent-driven architecture. It is multi-channel by design: the backend intelligence (Morgana) is decoupled from the channels users actually reach it through and ships with three reference channels out-of-the-boxCauldron (HTML / Blazor Server, rich UI over SignalR), Grimoire (TTY / Spectre.Console, rich CLI over webhook) and Rune (TTY / Spectre.Console, poor-but-honest CLI over webhook). Every channel advertises its capability budget at conversation start, Morgana adapts its outbound expressivity accordingly and each channel authenticates with its own JWT issuer.

Morgana reimagines conversational AI through 4 foundational pillars that work in harmony to deliver an orchestration framework that is powerful yet remarkably simple to configure.

🎭

Actor System

Fault-tolerant orchestration via Akka.NET. Each conversation is a hierarchy of specialized actors (Manager, Supervisor, Guard, Classifier, Router) communicating through async message passing.

🤖

Agent System

Domain specialists that self-register through declarative attributes. Supports native tools (MorganaTool), MCP server integration for runtime capability discovery and peer consultation between agents over the A2A protocol — with colleagues of this installation or of another Morgana it declares as consultable, indistinguishable to the agent that consults them.

📝

Prompt System

Prompts as versioned project artifacts with layered personality: a global Morgana character plus per-agent specializations. No hardcoded strings—iterate without redeployment.

💾

Context System

Isolated agent contexts with selective shared registry (first-write-wins). Encrypted SQLite persistence for conversation history and shared variables, with multi-agent timeline reconciliation and actor lifecycle resilience.

System Topology

ComponentRoleTechnologyDefault Port
MorganaBackend — SignalR Hub, Actors, AgentsASP.NET 10, Akka.NET, Microsoft.Agents.AI5001
CauldronChannel — HTML rich UI (SignalR delivery, full capability budget)Blazor Server5002
GrimoireChannel — TTY rich CLI (webhook delivery, full capability budget)Spectre.Console on Kestrel5004
RuneChannel — TTY CLI (webhook delivery, poor-but-honest capability budget)Spectre.Console on Kestrel5003
LLMLanguage model providerAnthropic, Azure OpenAI, Ollama, OpenAI
MCP ServerExternal tool provider (optional)ModelContextProtocol.Core (Http/Stdio)
A2A EndpointEvery agent published for peer consultation at /a2a/{intent}, with its well-known agent card (optional)Microsoft.Agents.AI.Hosting.A2A (JSON-RPC)5001

Reference Channels

Morgana ships with three reference channels out-of-the-box, chosen to populate the channel × capability matrix so the framework's multi-channel design is demonstrable without any extra work: a rich renderer in two different surfaces (HTML and TTY) plus a deliberately poor one that exercises the degradation path.

 HTMLTTY
FullCauldronGrimoire
PoorRune

Cauldron — HTML / Blazor Server (rich)

Cauldron is a Blazor Server application providing a rich, real-time chat interface with streaming responses, quick replies, rich cards and animated feedback. It communicates with Morgana over authenticated SignalR and REST (deliveryMode=signalr), with all capability flags on. It can be depicted as the official Morgana's home.

Cauldron Presentation Cauldron Chatting Cauldron Agent

Grimoire — TTY / Spectre.Console (rich)

Grimoire is the textual Cauldron: a Kestrel-hosted console app with a Spectre.Console Live UI that renders Morgana's full expressive surface inside the terminal — streaming responses, markdown, rich cards and quick replies, all capability flags on (MaxMessageLength=null). It uses the same webhook delivery mode (deliveryMode=webhook) as Rune, proving the framework adapts on the capability profile, not the transport. Content arrives intact and is “Spectrized” locally: markdown via a Markdig→Spectre renderer, rich cards mapped to bordered boxes, quick replies into an inline arrow-key selector.

Grimoire Presentation Grimoire Chatting Grimoire Agent

Rune — TTY / Spectre.Console (poor)

Rune is a minimal command-line channel: a Kestrel-hosted console app with a Spectre.Console Live UI (sticky header, streaming-free rendering), exercising the webhook delivery mode (deliveryMode=webhook) with lowest capability profile (all rich features off, MaxMessageLength=500). It is the contract surface MorganaChannelAdapter is supposed to degrade toward — a working proof that the channel abstraction holds, not just a demo.

Building Your Agents with Alembic

Agents can still be authored entirely by hand—agents.json and C# against the Morgana.AI NuGet package. But the preferred path is now Alembic, Morgana's authoring workbench: an AI-conducted interview that distills a new domain from scratch, or extends an existing one, into intents, agent prose, tool contracts and working C#—ready to be compiled into a Morgana plugin.

See the Alembic Handbook for the full workflow—from launching the interview to the generated assets.

Extension Points

Every behavioural concern in Morgana is independently overridable via Dependency Injection. The default implementations ship as sensible baselines—replace any of them without touching a single line of framework code.

InterfaceDefault ImplementationPurpose
ILLMServiceAnthropic / AzureOpenAI / Ollama / OpenAILLM provider abstraction
IAuthenticationServiceJWTAuthenticationServiceRequest authentication (JWT HMAC-SHA256)
IGuardRailServiceLLMGuardRailServiceContent moderation & policy enforcement
IClassifierServiceLLMClassifierServiceIntent classification
IPresenterServiceLLMPresenterServiceWelcome presentation & first engagement
IRateLimitServiceSQLiteRateLimitServicePer-conversation message frequency throttling
IDustLimitServiceSQLiteDustLimitServicePer-conversation token budget protection
IConversationPersistenceServiceSQLiteConversationPersistenceServiceEncrypted conversation storage
IAgentConfigurationServiceJsonAgentConfigurationServiceAgent discovery
IPromptResolverServiceEmbeddedResourcePromptResolverServiceSystem & Domain prompt loading
ISignalRBridgeServiceSignalRBridgeServiceReal-time frontend communication
ILLMTierValidationServiceRequiresLLMTierValidationServiceAgent LLM-tier declaration validation
IAgentDirectoryServiceConfigurationAgentDirectoryServiceA2A agent cards & colleague resolution
IHostAddressServiceKestrelHostAddressServiceAddress this instance publishes on its A2A cards
Plugin Architecture: Build your agents as standalone .NET libraries referencing the Morgana.AI NuGet package. Drop the compiled DLL into the plugins/ folder and Morgana discovers it at startup—zero configuration needed.

Security Model

Morgana implements a defence-in-depth strategy with security at every layer. The architecture is designed so that infrastructure-level concerns (TLS, vault, WAF) remain the deployer's responsibility, while the product guarantees application-level protection.

JWT Authentication (HMAC-SHA256)
Content Moderation (Guard)
Rate Limiting
Conversation Encryption (AES-256)
Secrets Externalized
Fail-Closed Auth
Authenticated A2A Endpoints

Authentication Flow

Channel (Cauldron iss=cauldron · Grimoire iss=grimoire · Rune iss=rune · Morgana herself iss=morgana when one agent consults another — each self-issues JWTs signed with its own shared key)
Bearer token in HTTP & SignalR / Webhook
Morgana (peeks iss, looks up the matching Issuers[] entry, validates signature + audience + lifetime; unknown issuers rejected outright)

Morgana uses a per-issuer trust model: each channel onboards with its own { Name, SymmetricKey } entry in Morgana:Authentication:Issuers[], so leaking one channel's key does not compromise the others. The same trust model covers peer consultation: an installation whose agents consult each other signs that traffic under an issuer named morgana, declared exactly like a channel. A missing, blank or still-placeholder key there is startup-fatal — without it a colleague resolves to nothing and the topology fails silently on the first conversation instead of at boot. Consulting another installation signs with the key that installation issued to this caller, held on its Morgana:AgentToAgent:OutboundSystems[] entry, so one such key opens nothing else. An installation with peer consultation enabled publishes every agent it holds, not only the ones its own agents consult: what it offers is what it can answer, so an agent modelled here is reachable by anything that speaks A2A — a sibling desk, another Morgana, an orchestrator this installation knows nothing about. The gate is the issuer and its declared reach, never the topology: publication stays whole and what narrows is admission. A caller proves who it is under Issuers[] and reaches only what its Morgana:AgentToAgent:InboundSystems[] entry allows — an entry naming Agents holding it to those, an entry naming none admitting it to all — which is how one company's several installations let each other reach only the desks that concern them. An agent that must not be answerable by a given caller is kept from it by that caller's scope, never by declining to publish it. A caller is a channel or a colleague, never both: Type is mandatory on every issuer and decides which door its key opens, because behind the A2A door a request reaches an agent's actor with none of the guard, classifier, rate limit and dust budget a channel's own path goes through. That door has ceilings of its own: an inbound system writes the name of the conversation it is served on, so how many it may open in a sliding hour is declared on its InboundSystems[] entry. What one exchange may then cost is the ordinary per-conversation budget. The A2A JSON-RPC endpoints carry the same fail-closed gate as the REST API; only the well-known agent card stays open, because discovery is what tells a caller how to authenticate — and it is now that card which says so literally: it declares the bearer scheme it requires and, through a published extension, the issuer and audience a caller must mint its token under, so the calling side configures a secret and nothing else. The IAuthenticationService extension point also allows deployers to replace JWT with any strategy (API keys, mTLS, OAuth with external IdP) without modifying the application.

Configuration Reference

Morgana and its reference channels all follow the standard ASP.NET configuration hierarchy: appsettings.json → environment variables → User Secrets. In Docker, environment variables in docker-compose.yml override everything.

Morgana (Backend)

SettingDescription
Morgana:LLM:ProviderLLM backend: Anthropic, AzureOpenAI, Ollama, OpenAI
Morgana:LLM:{Provider}:*Provider credentials (ApiKey, Endpoint, ...) + Tiers map keyed by die (Efficiency/Performance)
Morgana:LLM:{Provider}:Tiers:{Tier}:Options:*Narrow, JSON-bindable mirror of ChatOptions for this tier: ModelId (required), optional MaxOutputTokens
Morgana:LLM:{Provider}:Tiers:{Tier}:MagicDust:*Per-tier token pricing: InputTokensPerDustUnit, OutputTokensPerDustUnit, CachedInputWeight, CacheCreationWeight
Morgana:Authentication:AudienceExpected JWT audience (default: morgana.ai)
Morgana:Authentication:Issuers[]Per-channel trust list: array of { Name, SymmetricKey } entries (e.g. cauldron, grimoire, rune), plus morgana — with a real key — wherever agents consult each other
Morgana:ConversationPersistence:EncryptionKeyAES-256 key for SQLite encryption (base64)
Morgana:ConversationPersistence:StoragePathDirectory for SQLite database files
Morgana:ActorSystem:TimeoutSecondsActor/agent timeout (default: 180)
Morgana:ActorSystem:EnableGuardrailToggle content moderation guard
Morgana:AgentToAgent:*Peer consultation: Enabled (default true), MaxRoundsPerTurn (default 4), OutboundSystems[] — installations whose agents may be consulted, each { Name, Url, SymmetricKey, Issuer? }, where Name is what [ConsultsAgent] writes and never a hostname — and InboundSystems[], how far each admitted system reaches, each { Issuer, Agents?, MaxConversationsPerHour }. The inbound list admits nobody on its own and is required of every system issuer, morgana included, whose entry carries neither of the other two keys. MaxConversationsPerHour is required of every partner: startup refuses one admitted with no ceiling, since nothing reads an absent key as licence to spend without limit. No address setting for this instance — a card names it from the address Kestrel bound; an address is configured only for somebody else. See Agent-to-Agent Configuration for the three shapes in full
Morgana:AdaptiveMessaging:*Streaming toggle, rich-features threshold for ingress heuristic
Morgana:RateLimiting:*Per-minute, per-hour, per-day message limits
Morgana:DustLimiting:*Token budget: enabled, budget per conversation, warning & error messages, per-provider MagicDust pricing
Morgana:OpenTelemetry:*Tracing: exporters, service name, endpoints

Cauldron (HTML channel)

SettingDescription
Cauldron:MorganaURLMorgana backend URL
Cauldron:Authentication:SymmetricKeyHMAC-SHA256 key matching the cauldron entry in Morgana's Issuers[]
Cauldron:StreamingResponse:*Typewriter effect speed (tick ms, chars per tick)
Cauldron:LandingMessagesArray of random loading messages

Grimoire (TTY rich channel)

SettingDescription
Grimoire:MorganaURLMorgana backend URL
Grimoire:CallbackURLAbsolute URL Morgana posts inbound messages & stream chunks to (webhook listener)
Grimoire:Authentication:SymmetricKeyHMAC-SHA256 key matching the grimoire entry in Morgana's Issuers[]
Grimoire:StreamingResponse:*Typewriter cadence for the live streaming pane (tick ms, chars per tick)
Grimoire:MaxInputLengthHard cap on the typed prompt length in chars; typing past it is swallowed and a chars N/max header counter shows progress (default: 500)
Grimoire:LandingMessagesArray of random startup loading messages

Rune (TTY channel)

SettingDescription
Rune:MorganaURLMorgana backend URL
Rune:CallbackURLAbsolute URL Morgana posts inbound messages to (webhook listener)
Rune:Authentication:SymmetricKeyHMAC-SHA256 key matching the rune entry in Morgana's Issuers[]
Rune:MaxInputLengthHard cap on the typed prompt length in chars; typing past it is swallowed and a chars N/max header counter shows progress (default: 500)
Rune:MaxMessageLengthBudget capability declared for maximum message length (default: 500)

Agent-to-Agent Configuration

Peer consultation is configured in three shapes. Which one a deployment is in follows entirely from what its plugins declare with [ConsultsAgent] plus what Morgana:AgentToAgent says. Two rules run through all of them. Identity and reach are separate questions: Issuers[] answers who may knock, InboundSystems[] how far a knocker reaches and neither implies the other. And this installation never declares its own address — a published card names it from whatever Kestrel bound, so a URL is configured only for somebody else.

1 — Off

Nothing is stood up: no hosted agent, no A2A server, no route, no card. Agents never see the colleagues they declare and read not one extra token of prompt, so a deployment here is the deployment it was before any of this existed. [ConsultsAgent] attributes stay in the code and are simply not honoured. No morgana issuer is required.

"AgentToAgent": { "Enabled": false }

2 — On, colleagues of this installation only

The ring is raised whole: every agent this installation holds is published, whether or not a sibling consults it. A local consultation leaves over HTTP signed under the morgana issuer and knocks back at this installation's own A2A door, which is why that issuer is needed at both ends. All three pieces below are checked at startup and a missing one is fatal — a topology that validates and then fails silently on the first conversation is exactly what those checks exist to prevent.

"AgentToAgent": { "Enabled": true, "MaxRoundsPerTurn": 4, "OutboundSystems": [], "InboundSystems": [ { "Issuer": "morgana" } // no Agents, no MaxConversationsPerHour ] }, "Authentication": { "Audience": "morgana.ai", "Issuers": [ { "Name": "cauldron", "Type": "channel", "SymmetricKey": "_SECURE_OVERRIDE_" }, { "Name": "morgana", "Type": "system", "SymmetricKey": "_SECURE_OVERRIDE_" } ] }

The morgana entry carries neither Agents nor MaxConversationsPerHour. Startup refuses both. Which colleagues an agent here may consult already has one author, [ConsultsAgent]; and a colleague of ours opens no conversation, it joins the one the user is already having.

3 — On, colleagues here and at another installation

The two directions are independent and neither implies the other. Outbound is what this side must bring to consult a partner: an entry naming that partner, its address and the key it issued to this caller. No Issuers[] entry is needed for a system this installation only calls. Inbound is a partner calling us. That needs both — an Issuers[] entry typed system carrying the key we issued to them, plus an InboundSystems[] entry saying how far it reaches and how often it may come back.

"AgentToAgent": { "Enabled": true, "MaxRoundsPerTurn": 4, // Partners this installation may consult. Name is what [ConsultsAgent] writes, // never a hostname. Issuer is optional: it overrides what their card declares, // for a key they cut for this caller alone. "OutboundSystems": [ { "Name": "acme", "Url": "https://acme.example.com", "SymmetricKey": "_SECURE_OVERRIDE_" } ], // How far each admitted system reaches. Omitting Agents admits it to every // published agent. MaxConversationsPerHour is required of every partner — // write a generous number for a trusted one, never nothing. "InboundSystems": [ { "Issuer": "morgana" }, { "Issuer": "acme", "Agents": ["billing", "inventory"], "MaxConversationsPerHour": 100 } ] }, "Authentication": { "Audience": "morgana.ai", "Issuers": [ { "Name": "cauldron", "Type": "channel", "SymmetricKey": "_SECURE_OVERRIDE_" }, { "Name": "morgana", "Type": "system", "SymmetricKey": "_SECURE_OVERRIDE_" }, { "Name": "acme", "Type": "system", "SymmetricKey": "_SECURE_OVERRIDE_" } ] }
// The declaration in code. A colleague published elsewhere is offered under a // function name carrying its system, so two desks of the same name never collide. [HandlesIntent("billing")] [RequiresLLMTier(Records.LLMTier.Efficiency)] [ConsultsAgent("inventory")] // a colleague here → consult_inventory [ConsultsAgent("shipping", "acme")] // a colleague at acme → consult_acme_shipping public class BillingAgent : MorganaAgent { /* ... */ }

What each shape requires

OffLocal onlyLocal + remote
morgana in Issuers[]required, Type: systemrequired, Type: system
morgana in InboundSystems[]required, barerequired, bare
Partner in Issuers[]only if it calls us
Partner in InboundSystems[]only if it calls us — then mandatory, ceiling included
Partner in OutboundSystems[]only if we call it
Agents publishednoneall of themall of them
Prompt cost per agentnonepeer policy + colleaguespeer policy + colleagues

A pure supplier — publishing its desks for a partner while its own agents consult nobody here — is the one shape needing no morgana entry at all: nothing of it ever knocks at its own door.

Docker Deployment

Morgana ships with a ready-to-use docker-compose.yml defining Morgana, Cauldron, Grimoire and Rune on a dedicated bridge network with persistent SQLite volume. docker compose up starts Morgana + Cauldron; Grimoire and Rune are TUIs that need their own terminal, so each is launched interactively on demand in a separate shell (and only one TTY channel can own stdin/stdout at a time).

# Quick Start (production images from Docker Hub) cp production.env.template .env nano .env # fill in secrets docker compose up # Morgana + Cauldron (HTML on :5002) docker compose run --rm --service-ports --use-aliases grimoire # Grimoire TUI (rich TTY on :5004) docker compose run --rm --service-ports --use-aliases rune # Rune TUI (poor TTY on :5003) — `--use-aliases` registers the service as a network alias so Morgana's webhook callback resolves # Development (build from source) cp development.env.template .env nano .env dotnet build ./Morgana && dotnet build ./Channels/Cauldron && dotnet build ./Channels/Grimoire && dotnet build ./Channels/Rune docker compose --env-file .env --env-file .env.versions build docker compose --env-file .env --env-file .env.versions up

Required Secrets (.env)

VariablePurposeGeneration
ENCRYPTIONKEYAES-256 encryption for SQLiteopenssl rand -base64 32
JWT_SYMMETRIC_KEYJWT signing key shared between each channel and its Issuers[] entry on Morgana; also signs the peer traffic of the morgana issuer when agents consult each otheropenssl rand -base64 32
LLM_PROVIDERActive LLM backendOne of: anthropic, azureopenai, ollama, openai
{PROVIDER}_APIKEYLLM provider API keyFrom your provider dashboard (if cloud-based)

Observability (OpenTelemetry)

Morgana provides end-to-end distributed tracing across the entire conversation pipeline. Traces are structured to be meaningful both to IT operators (latencies, TTFT, errors) and non-technical stakeholders (intent, agent name, response preview).

Span Architecture

SpanKey Attributes
morgana.turnconversation.id, turn.user_message
morgana.guardguard.compliant, guard.violation
morgana.classifierclassification.intent, classification.confidence
morgana.routerrouter.intent, router.agent_path
morgana.agentagent.name, agent.ttft_ms, agent.response_preview
morgana.consultationconsultation.caller, consultation.target, consultation.awaiting_reply

Exporter Configuration

"OpenTelemetry": { "Enabled": true, "ServiceName": "Morgana", "Exporters": [ { "Name": "console", "Enabled": true }, { "Name": "otlp", "Enabled": true, "Endpoint": "http://localhost:4317" } ] }

Compatible with Jaeger, Grafana Tempo, Azure Monitor, Datadog and any OTLP-compliant backend.

Quick Reference

ResourceLocation
Source codegithub.com/mdesalvo/Morgana
NuGet packagenuget.org/packages/Morgana.AI
Docker Hub (Morgana)hub.docker.com/r/mdesalvo/morgana
Docker Hub (Cauldron)hub.docker.com/r/mdesalvo/cauldron
Docker Hub (Grimoire)hub.docker.com/r/mdesalvo/grimoire
Docker Hub (Rune)hub.docker.com/r/mdesalvo/rune
Alembic (authoring workbench)Alembic/ in repository root — see the Alembic Handbook
Automated prompt testsPromptHarness/ in repository root
ChangelogCHANGELOG.md in repository root