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
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-box — Cauldron (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.
Fault-tolerant orchestration via Akka.NET. Each conversation is a hierarchy of specialized actors (Manager, Supervisor, Guard, Classifier, Router) communicating through async message passing.
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.
Prompts as versioned project artifacts with layered personality: a global Morgana character plus per-agent specializations. No hardcoded strings—iterate without redeployment.
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.
| Component | Role | Technology | Default Port |
|---|---|---|---|
| Morgana | Backend — SignalR Hub, Actors, Agents | ASP.NET 10, Akka.NET, Microsoft.Agents.AI | 5001 |
| Cauldron | Channel — HTML rich UI (SignalR delivery, full capability budget) | Blazor Server | 5002 |
| Grimoire | Channel — TTY rich CLI (webhook delivery, full capability budget) | Spectre.Console on Kestrel | 5004 |
| Rune | Channel — TTY CLI (webhook delivery, poor-but-honest capability budget) | Spectre.Console on Kestrel | 5003 |
| LLM | Language model provider | Anthropic, Azure OpenAI, Ollama, OpenAI | — |
| MCP Server | External tool provider (optional) | ModelContextProtocol.Core (Http/Stdio) | — |
| A2A Endpoint | Every agent published for peer consultation at /a2a/{intent}, with its well-known agent card (optional) | Microsoft.Agents.AI.Hosting.A2A (JSON-RPC) | 5001 |
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.
| HTML | TTY | |
|---|---|---|
| Full | Cauldron | Grimoire |
| Poor | — | Rune |
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.
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.
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.
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.
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.
| Interface | Default Implementation | Purpose |
|---|---|---|
| ILLMService | Anthropic / AzureOpenAI / Ollama / OpenAI | LLM provider abstraction |
| IAuthenticationService | JWTAuthenticationService | Request authentication (JWT HMAC-SHA256) |
| IGuardRailService | LLMGuardRailService | Content moderation & policy enforcement |
| IClassifierService | LLMClassifierService | Intent classification |
| IPresenterService | LLMPresenterService | Welcome presentation & first engagement |
| IRateLimitService | SQLiteRateLimitService | Per-conversation message frequency throttling |
| IDustLimitService | SQLiteDustLimitService | Per-conversation token budget protection |
| IConversationPersistenceService | SQLiteConversationPersistenceService | Encrypted conversation storage |
| IAgentConfigurationService | JsonAgentConfigurationService | Agent discovery |
| IPromptResolverService | EmbeddedResourcePromptResolverService | System & Domain prompt loading |
| ISignalRBridgeService | SignalRBridgeService | Real-time frontend communication |
| ILLMTierValidationService | RequiresLLMTierValidationService | Agent LLM-tier declaration validation |
| IAgentDirectoryService | ConfigurationAgentDirectoryService | A2A agent cards & colleague resolution |
| IHostAddressService | KestrelHostAddressService | Address this instance publishes on its A2A cards |
plugins/ folder and Morgana discovers it at startup—zero configuration needed.
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.
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)
→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.
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.
| Setting | Description |
|---|---|
| Morgana:LLM:Provider | LLM 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:Audience | Expected 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:EncryptionKey | AES-256 key for SQLite encryption (base64) |
| Morgana:ConversationPersistence:StoragePath | Directory for SQLite database files |
| Morgana:ActorSystem:TimeoutSeconds | Actor/agent timeout (default: 180) |
| Morgana:ActorSystem:EnableGuardrail | Toggle 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 |
| Setting | Description |
|---|---|
| Cauldron:MorganaURL | Morgana backend URL |
| Cauldron:Authentication:SymmetricKey | HMAC-SHA256 key matching the cauldron entry in Morgana's Issuers[] |
| Cauldron:StreamingResponse:* | Typewriter effect speed (tick ms, chars per tick) |
| Cauldron:LandingMessages | Array of random loading messages |
| Setting | Description |
|---|---|
| Grimoire:MorganaURL | Morgana backend URL |
| Grimoire:CallbackURL | Absolute URL Morgana posts inbound messages & stream chunks to (webhook listener) |
| Grimoire:Authentication:SymmetricKey | HMAC-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:MaxInputLength | Hard 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:LandingMessages | Array of random startup loading messages |
| Setting | Description |
|---|---|
| Rune:MorganaURL | Morgana backend URL |
| Rune:CallbackURL | Absolute URL Morgana posts inbound messages to (webhook listener) |
| Rune:Authentication:SymmetricKey | HMAC-SHA256 key matching the rune entry in Morgana's Issuers[] |
| Rune:MaxInputLength | Hard 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:MaxMessageLength | Budget capability declared for maximum message length (default: 500) |
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.
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.
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.
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.
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.
| Off | Local only | Local + remote | |
|---|---|---|---|
morgana in Issuers[] | — | required, Type: system | required, Type: system |
morgana in InboundSystems[] | — | required, bare | required, 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 published | none | all of them | all of them |
| Prompt cost per agent | none | peer policy + colleagues | peer 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.
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).
| Variable | Purpose | Generation |
|---|---|---|
| ENCRYPTIONKEY | AES-256 encryption for SQLite | openssl rand -base64 32 |
| JWT_SYMMETRIC_KEY | JWT 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 other | openssl rand -base64 32 |
| LLM_PROVIDER | Active LLM backend | One of: anthropic, azureopenai, ollama, openai |
| {PROVIDER}_APIKEY | LLM provider API key | From your provider dashboard (if cloud-based) |
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 | Key Attributes |
|---|---|
| morgana.turn | conversation.id, turn.user_message |
| morgana.guard | guard.compliant, guard.violation |
| morgana.classifier | classification.intent, classification.confidence |
| morgana.router | router.intent, router.agent_path |
| morgana.agent | agent.name, agent.ttft_ms, agent.response_preview |
| morgana.consultation | consultation.caller, consultation.target, consultation.awaiting_reply |
Compatible with Jaeger, Grafana Tempo, Azure Monitor, Datadog and any OTLP-compliant backend.
| Resource | Location |
|---|---|
| Source code | github.com/mdesalvo/Morgana |
| NuGet package | nuget.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 tests | PromptHarness/ in repository root |
| Changelog | CHANGELOG.md in repository root |