Architecture Overview
MangoStudio follows a modular DDD-inspired architecture across three workspaces. This document explains the design decisions, layer responsibilities, and data flow.
Workspace Map
| Workspace | Role | Stack |
|---|---|---|
apps/api |
Backend API hub | Elysia, Better Auth, Kysely + SQLite |
apps/frontend |
Browser SPA | React 19, TanStack Router/Query, Tailwind CSS v4, Bun.build() bundle |
apps/shared |
Contracts, and the code both machines run | TypeScript types, TypeBox schemas, i18n dictionaries; host-only code behind its own export subpath |
Published alongside the application
The repository also hosts the Mango Protocol — the wire contract the hub and the runtime
speak — on its own version line and its own protocol-v* release train. It is not one of the
three application workspaces and does not follow their lifecycle.
| Path | Ships as | Stack |
|---|---|---|
spec/ |
The normative spec, schemas and fixtures | Markdown, JSON Schema 2020-12 |
packages/protocol/ |
@mangostudio/protocol on npm |
TypeScript, TypeBox |
crates/mango-protocol/ |
mango-protocol on crates.io |
Rust, serde, tokio |
crates/mangostudio-launcher/ |
mangostudio on crates.io (the launcher) |
Rust, application version and shared workspace MSRV |
Both stable Rust crates belong to the root Cargo workspace and share its lockfile, edition,
development toolchain, and lint policy. The protocol and application versions remain independent.
crates/mango-protocol/fuzz is an excluded nightly-only workspace with its own lockfile.
packages/protocol/AGENTS.md is the contributor guide for that tree, including the
contract-change procedure every wire change follows. docs/protocol/ holds the adoption guides.
API Module Architecture
The API is organized into domain modules under apps/api/src/modules/. Each module follows a DDD-inspired structure:
modules/<domain>/
application/ → Use-case services (orchestration logic)
domain/ → Domain rules and entities (optional — omitted for simple modules)
http/ → Elysia route definitions
infrastructure/ → Repository/persistence implementations
Layer Responsibilities
application/ — Orchestration services that implement business use cases. They coordinate between repositories, services, and domain logic. Examples:
stream-text-turn.ts— the main generation loop orchestrating providers, tools, and continuation.create-chat.ts— creates a new chat with ownership validation.prompt-composer.ts— assembles system prompts with rule composition.
domain/ — Pure functions and types describing domain invariants. Present only when business rules need isolation from infrastructure. Examples:
chat-ownership.ts— ownership assertion rules.connector.ts— connector entity validation.
http/ — Elysia route plugins that handle HTTP concerns (request parsing, auth guards, response serialization). They delegate to application services.
infrastructure/ — Kysely-based repository implementations that abstract SQL queries behind typed interfaces. One repository per aggregate root.
Modules
| Module | Layers | Responsibilities |
|---|---|---|
chats |
A/D/H/I | Chat CRUD, ownership, context compaction |
messages |
A/D/H/I | Message persistence, gallery queries |
generation |
A/D/H/I | Streaming text turns, image generation, model resolution |
connectors |
A/D/H/I | API key management, model enable/disable, secret persistence |
app-settings |
A/H/I | Global user settings (theme, language, generation defaults) |
provider-settings |
A/H/I | Per-provider runtime configuration (caching, reasoning, tokens) |
tool-settings |
A/H/I | Per-tool enable/disable and parameter overrides |
prompt-rules |
A/H | System prompt composition, rule file resolution |
attachments |
A/I | File upload, validation, storage, provider delivery |
realtime |
H | Cookie-authenticated, user-scoped cache invalidation |
activity |
A/D/H/I | Account-wide “what changed” log; many modules write, one reads |
A = Application, D = Domain, H = HTTP, I = Infrastructure
When to Skip Layers
Simple modules skip layers that would add ceremony without value:
- No
domain/— when business rules are self-evident (e.g., app-settings is a simple CRUD wrapper with normalization). - No
http/— when the module is purely infrastructural (e.g.,generated-images/only has a repository). - No
application/— only for bridge modules whose policy and data work is delegated to existing services (for example,realtimedelegates fan-out to the realtime bus and ownership checks tochats).
Data Flow
Typical API Request
Browser (Eden Treaty)
│
▼
Elysia Route (http/)
├─ requireAuth middleware
├─ TypeBox body validation
│
▼
Application Service (application/)
├─ Ownership check (domain/)
├─ Business logic
├─ Repository calls (infrastructure/)
│
▼
Response (typed via shared contracts)
Streaming Request (Chat)
Browser (fetch + ReadableStream)
│
▼
respond-stream-routes.ts
├─ Pre-flight validation (ownership, content, model)
│
▼
stream-text-turn.ts (orchestrator)
├─ Resolve model + provider
├─ Compose prompt rules
├─ Decide continuation strategy
├─ Call provider.generateAgentTurnStream()
├─ Execute tools (if model calls them)
├─ Enforce subagent delegation response contract (delegate_to_agent)
├─ Feed tool results back to provider
└─ Persist turn → yield SSE events
Hub/Runtime Execution
Filesystem tools, shell commands, freshness tracking, and checkpoint effects execute through
the versioned runtime protocol. The API remains the hub for workdir policy, orchestration,
and durable checkpoint state. See hub-runtime.md for the ownership
table, protocol lifecycle, and transport roadmap.
Every runtime, Local included, is the cargo-built mangostudio-runtime from
crates/mangostudio-runtime. The hub spawns Local on its own machine over stdio: the sibling
binary beside a standalone hub, or in a source checkout the newest target/debug or
target/release build, which bun run dev builds first. MANGOSTUDIO_RUNTIME_BINARY
overrides both, and a missing binary fails the connect with cargo build -p mangostudio-runtime rather than falling back to anything.
Realtime Invalidation
/api/ws is an invalidation-only bridge from the user-scoped in-process bus to
authenticated browser tabs. Entity data continues to travel over HTTP. See
realtime.md for the protocol, security boundaries, limits,
degradation model, and topic-extension workflow.
Subagent Delegation Enforcement
Subagent delegation is executed through the delegate_to_agent tool. The orchestrator enforces a response contract so the parent agent always receives a usable final result, even when a provider ends a subagent run after tool calls without emitting assistant text.
- Response contract: the delegation tool result must be a non-empty
SubagentRunResultwith a non-emptysummary,trace.lastMessage, and at least one assistant message intrace.messages. - Tool-only runs: if a subagent executes tools but produces no assistant text, the runtime synthesizes a summary (including the tool names executed) and emits a
subagent_textevent for UI consistency. - Main-agent verification: the orchestrator validates that the delegation result satisfies the response contract; if it is missing or invalid, it retries up to 3 times with exponential backoff and stricter output requirements, then falls back to a standardized failure result.
- Response persistence: subagent text deltas and final results are stored in an in-memory cache keyed by tool call id so the orchestrator can recover a usable result if the final payload is missing or invalid.
- Debug logging: structured lifecycle logs are emitted with
[subagent]and delegation enforcement logs with[subagent-delegation], including call ids, tool counts, and summary lengths (without logging tool result contents).
Cross-Cutting Concerns
Auth
Better Auth handles sessions via cookie-based authentication. The
auth-middleware.ts plugin decorates routes with requireAuth, which validates
the session and injects the user context. External automation uses scoped API
keys: callers send x-api-key, apiKeyGuard enforces the per-user toggle and
scope, and Better Auth resolves the key into the same session path so
requireAuth stays unchanged. See reference/external-api.md.
Rate Limiting
In-memory rate limiter (rate-limit.ts) that counts requests per (bucket,
client id). A classify function (rate-limit-policy.ts) sorts each request
into a named bucket — health, auth, general (cookie traffic), and
api-key (requests with x-api-key) use independent counters so automation
cannot starve browser sessions on the same IP. general is the widest of them:
the narrower buckets each bound one expensive operation, while general bounds
ordinary page loads, and one page load is many requests. Client id is the caller
IP for every bucket today (per-key ids need a verified key id after
apiKeyGuard; see issue #737). Limited requests return a 429 ApiErrorResponse
(code: RATE_LIMITED) with a Retry-After header. Expired counters are cleaned
up lazily. Proxy headers affect the client id only when trustProxy is
explicitly enabled behind a trusted reverse proxy.
Error Handling
- API errors use
ApiErrorResponsefrom@mangostudio/shared/errors:{ error: string, code?: string, details?: Record<string, string> }. The HTTP status is carried by the response, not the body. - Streaming errors use
SSEErrorEventfrom@mangostudio/shared/streaming:{ type: 'error', error, done: true }. - Domain errors extend
Errorwith typed codes (e.g.,ChatNotFoundError,ToolParameterError). - The centralized
error-handler.tsplugin maps thrown errors to HTTP responses.
Validation
- Request bodies validated via TypeBox schemas in shared contracts.
- Response shapes validated in integration tests via
Value.Check. - Attachment uploads validated for MIME type, magic bytes, size, and UTF-8 correctness.
Environment Installs
Environment installation is a guarded local-only execution surface built from code-owned recipes,
bounded downloads and output, user-scoped audit records, and raw SSE logs. See
environment-installs.md for its threat model and opt-in controls.
Provider Architecture
Providers implement the AIProvider interface. The provider registry maps provider types to implementations. The bundled providers are Gemini, OpenAI, OpenAI-compatible, Anthropic, DeepSeek and ChatGPT.
Cursor is registered but deprecated. It advertises no models and refuses execution; the supported path is the external-agent adapter, where Cursor’s own CLI owns the model, the tools and the approvals. resolve-model.ts is where that refusal is raised, before provider resolution, so every path to a provider passes through it. See ../providers/cursor.md.
Key abstraction layers:
stream-text-turn.ts (orchestrator)
│
├─ resolve-model.ts → selects provider + model, refuses deprecated ones
├─ continuation-runtime.ts → decides strategy
│
▼
Provider Implementation (e.g., gemini/interactions-stream.ts)
├─ generateAgentTurnStream() → yields AgentEvent[]
├─ continuation-envelope.ts → serializes cursor state
│
▼
Provider Wire Format (Gemini / OpenAI / Anthropic / DeepSeek)
See continuation.md for the full continuation architecture and ../providers/development.md for the provider integration guide.
Frontend Architecture
routes/ → TanStack Router file-based pages
_authenticated/ → Auth-protected routes
index.tsx → Chat (default)
gallery.tsx → Image gallery
settings.tsx → Settings layout
studio.tsx → Image workspace
environments.tsx → Environments umbrella layout
environments/ → Toolchains, agents, health
library.tsx → Library section layout (second-level tabs)
library/ → Coverage matrix, backups, resource detail
library/ → Redirect stubs for pre-move /library/* bookmarks
features/ → Domain feature modules
chat/ → Chat UI, components, hooks, services
environments/ → Environment cards, health, install actions
gallery/ → Gallery page + queries
generation/ → Text/image generation hooks + types
library/ → Coverage matrix, divergence, propagation wizards
settings/ → Settings sub-modules (app, connectors, providers, tools, prompts)
sidebar/ → Sidebar + context ring
components/ → Shared UI components
ui/ → Design system primitives (Button, Input, Card, etc.)
layout/ → Header, Layout, ModelSelector, ThinkingToggle
MarkdownContent.tsx → Markdown rendering with syntax highlighting
Frontend Data Flow
TanStack Query (cache + invalidation)
│
▼
Eden Treaty Client (typed API client)
│
▼
Elysia API (server)
Shared Package
The shared package is framework-agnostic and importable by both API and frontend:
shared/src/
contracts/ → Barrel export of all contract types
<module>/ → Per-module contracts + TypeBox schemas
realtime/ → WebSocket invalidation messages, topics, and close codes
runtime-contract/ → Hub/runtime methods, capabilities, events, manifest, errors
methods/ → One schema module per method family (fs, mcp, library, …)
generated/ → Emitted catalog + schemas a non-TypeScript runtime is built from
streaming/ → SSE event types + schemas
types/ → Domain types (provider, agent-events, gallery)
i18n/ → Portuguese/English dictionaries + type system
test-utils/ → Shared mock factories
Cross-workspace imports use package names (@mangostudio/shared), never relative paths.
Database
SQLite via Kysely with 18 migrations covering:
- Core tables:
chats,messages,generated_images,chat_attachments - Auth tables: Better Auth standard schema (5 tables)
- Settings tables:
user_app_settings,user_provider_settings,user_tool_settings
Columns use camelCase, tables use snake_case. Kysely type aliases: <Entity>Select, <Entity>Insert, <Entity>Update.
Standalone Build
bun run build --binary compiles the API into platform-specific binaries via bun build --compile, with the frontend embedded, and stages the cargo-built mangostudio-runtime beside each. Distribution builds all 8 platforms (linux/windows/darwin × x64/arm64 + glibc/musl) from per-target runtimes (--runtime-dir); a local build covers the host’s own platform (--platform <host>) — see releasing.md. Database defaults to ~/.mango/database.sqlite.
Each runtime is a native binary built with fat LTO and panic = "unwind" (handler panic isolation depends on it): gnu Linux targets link at the glibc 2.17 floor, musl targets are static, Windows links the MSVC C runtime statically, and every binary reports the release version it was stamped with. Release assets, runtime binaries included, are listed in an attested SHA256SUMS; runtime-metrics.md records their sizes.