feat: Tenant/User/Campaign Admin Web UI
Overview
Build a web-based administration panel for managing Glyphoxa tenants, users, campaigns, NPCs, sessions, providers, and usage/billing. The UI will sit on top of the existing Admin API (and its required extensions), providing a graphical interface for everything currently done via curl against /api/v1/tenants.
Problem Statement / Motivation
Today, Glyphoxa management is entirely API-driven β tenant CRUD goes through the Admin API with X-API-Key auth, NPC definitions live in YAML config files, and thereβs no visibility into active sessions, usage, or provider health without querying the database directly. This works for a single developer but does not scale to:
- Multiple tenants who need self-service campaign/NPC management
- DMs who want to configure NPCs, voices, and personalities without editing YAML
- Operators who need session monitoring, usage dashboards, and provider health
- Billing workflows that require quota visibility and adjustment
A web UI makes Glyphoxa accessible to non-technical users and provides operational visibility for production deployments.
Current State Analysis
What the Admin API covers today
| Endpoint | Method | Purpose |
|---|---|---|
POST /api/v1/tenants | POST | Create tenant |
GET /api/v1/tenants | GET | List all tenants |
GET /api/v1/tenants/{id} | GET | Get tenant by ID |
PUT /api/v1/tenants/{id} | PUT | Update tenant |
DELETE /api/v1/tenants/{id} | DELETE | Delete tenant |
Auth: Single shared GLYPHOXA_ADMIN_API_KEY (Bearer token or X-API-Key header).
Data model (Tenant):
id,license_tier(shared/dedicated),bot_token,guild_ids[],dm_role_id,campaign_id,monthly_session_hours, timestamps
Whatβs missing from the API
| Domain | Status | Notes |
|---|---|---|
| Tenant CRUD | Exists | Fully functional |
| Campaign CRUD | Missing | Campaigns defined in YAML, no API |
| NPC CRUD | Partial | npcstore.PostgresStore exists but not exposed via HTTP |
| User/Role mgmt | Missing | No user model β only API key + Discord dm_role_id |
| Session monitoring | Missing | sessionorch has data but no API to query it |
| Usage/billing | Missing | usage.Store has data but no API |
| Provider config | Missing | Providers set via YAML config, no runtime API |
| Health/metrics | Partial | /healthz, /readyz exist; Prometheus metrics exposed |
Existing database tables
Gateway DB:
tenantsβ tenant records (with Vault-encrypted bot tokens)sessionsβ voice session lifecycle (state machine: pendingβactiveβended)usage_recordsβ monthly aggregates per tenant (session_hours, llm_tokens, stt_seconds, tts_chars)
Application DB (per-tenant schema):
npc_definitionsβ NPC config (personality, voice, engine, knowledge, tools, budget tier)session_entriesβ L1 transcript log (speaker, text, raw_text, npc_id, timestamps)chunksβ L2 semantic embeddings (pgvector)entitiesβ L3 knowledge graph nodesrelationshipsβ L3 knowledge graph edgessessions(memory) β session metadata (start/end times)recapsβ generated session recap text + audio
Architecture Decision: Where Does the UI Live?
Option A: Embedded in the Gateway (Recommended)
The web UI is a static SPA served by the gateway process. The gatewayβs existing HTTP server (AdminAPI.Handler()) is extended with new API endpoints and a file server for static assets.
βββββββββββββββββββββββββββββββββββββββββββββββ
β Gateway β
β β
β βββββββββββ ββββββββββββ ββββββββββββββ β
β βAdmin APIβ β New API β β Static SPA β β
β β(tenants)β β endpoints β β (React) β β
β ββββββ¬βββββ βββββββ¬βββββ ββββββββ¬ββββββ β
β β β β β
β βββββββββββββββΌβββββββββββββββ β
β β β
β ββββββββ΄βββββββ β
β β http.ServeMuxβ β
β βββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββ
Pros:
- Zero additional deployment complexity β same binary, same container
- SPA assets embedded via
embed.FS(Go 1.16+) β no separate build step in prod - Shares the same DB connections, stores, and auth middleware
- Natural CORS-free setup (same origin)
- Health checks, metrics, and TLS all inherited
Cons:
- Frontend dev requires a proxy or dev server during development
- Gateway binary grows ~2-5MB (compressed SPA assets)
- Tight coupling to gateway release cycle
Option B: Separate Service
A standalone web server (Node.js or Go) that calls the Admin API over HTTP.
Pros: Independent deployment, separate scaling, choice of any tech stack. Cons: Extra service to deploy/monitor, API key management, CORS, network hop, duplicated auth logic. Overkill for current scale.
Decision
Option A β embed in the gateway. Glyphoxa is a single-team project deployed on K3s. Adding a separate service adds operational overhead without proportional benefit. The SPA can always be extracted later if needed.
Tech Stack
Frontend: React + Vite + Tailwind CSS
| Choice | Rationale |
|---|---|
| React 19 | Most widely known, huge ecosystem, Luk can find contributors |
| Vite | Fast HMR, modern bundling, small config surface |
| Tailwind | Utility-first CSS, no custom design system needed |
| React Router | Client-side routing for SPA |
| TanStack Query | Server-state management, caching, optimistic updates |
| shadcn/ui | Copy-paste component library on top of Radix β accessible, customizable, no npm lock-in |
| TypeScript | Type safety for API contracts |
The built SPA is embedded into the Go binary via //go:embed at compile time. During development, Viteβs dev server proxies API requests to the gateway.
Backend: Extend existing Go Admin API
New endpoints follow the existing pattern in admin.go β handler functions registered on the shared http.ServeMux, guarded by authMiddleware.
Why not HTMX/Go templates?
HTMX is tempting for simplicity but:
- NPC voice preview requires client-side audio playback (Web Audio API)
- Session monitoring benefits from WebSocket live updates
- Drag-and-drop NPC ordering, rich text for personalities β these need JS anyway
- React + TanStack Query gives better offline/optimistic UX for CRUD-heavy pages
Authentication & Authorization
Phase 1 (MVP): API Key Auth
Keep the existing GLYPHOXA_ADMIN_API_KEY mechanism. The SPA stores the key in a session cookie (HttpOnly, Secure, SameSite=Strict) after the user enters it on a login screen. All API requests include Authorization: Bearer <key>.
This is sufficient for single-operator deployments.
Phase 2: User Auth with Roles
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Auth Flow β
β β
β Discord OAuth2 βββ JWT issued βββ SPA stores JWT β
β β
β JWT contains: user_id, tenant_id, role β
β Roles: super_admin, tenant_admin, dm, viewer β
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
User model (new users table):
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, -- UUID
tenant_id TEXT NOT NULL REFERENCES tenants(id),
discord_id TEXT UNIQUE, -- Discord user snowflake
email TEXT,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer', -- super_admin, tenant_admin, dm, viewer
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_users_tenant ON users(tenant_id);
CREATE INDEX idx_users_discord ON users(discord_id);
Role permissions:
| Action | super_admin | tenant_admin | dm | viewer |
|---|---|---|---|---|
| Manage tenants | β | Β | Β | Β |
| Manage provider config | β | Β | Β | Β |
| Manage users in own tenant | β | β | Β | Β |
| Manage campaigns | β | β | β | Β |
| Manage NPCs | β | β | β | Β |
| View sessions/transcripts | β | β | β | β |
| View usage/billing | β | β | Β | Β |
| Start/stop sessions (API) | β | β | β | Β |
OAuth2 flow:
- User clicks βLogin with Discordβ
- Redirect to Discord OAuth2 (
identify+guildsscopes) - Backend exchanges code for Discord user info
- Match
discord_idto existing user record (or auto-provision asviewer) - Issue JWT (HS256, 24h expiry) with
{user_id, tenant_id, role} - SPA stores JWT, includes in all API requests
API Extensions Required
Campaign API
POST /api/v1/campaigns Create campaign
GET /api/v1/campaigns List campaigns (filterable by tenant)
GET /api/v1/campaigns/{id} Get campaign
PUT /api/v1/campaigns/{id} Update campaign
DELETE /api/v1/campaigns/{id} Delete campaign (cascade NPCs?)
New table:
CREATE TABLE IF NOT EXISTS campaigns (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
system TEXT NOT NULL DEFAULT '', -- dnd5e, pf2e, etc.
description TEXT NOT NULL DEFAULT '',
settings JSONB NOT NULL DEFAULT '{}', -- game-specific config
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_campaigns_tenant ON campaigns(tenant_id);
Impact: The current Tenant.CampaignID (single string) becomes a foreign key into this table. Tenants on dedicated tier can have multiple campaigns.
NPC API
Expose the existing npcstore.PostgresStore via HTTP:
POST /api/v1/campaigns/{campaign_id}/npcs Create NPC
GET /api/v1/campaigns/{campaign_id}/npcs List NPCs for campaign
GET /api/v1/npcs/{id} Get NPC
PUT /api/v1/npcs/{id} Update NPC
DELETE /api/v1/npcs/{id} Delete NPC
POST /api/v1/npcs/{id}/voice-preview Generate TTS preview audio
The NPC store already supports all CRUD operations β this is primarily wiring HTTP handlers to existing npcstore.Store methods.
User API (Phase 2)
POST /api/v1/users Create user
GET /api/v1/users List users (filtered by tenant)
GET /api/v1/users/{id} Get user
PUT /api/v1/users/{id} Update user (role changes)
DELETE /api/v1/users/{id} Delete user
GET /api/v1/auth/discord Initiate Discord OAuth2
GET /api/v1/auth/discord/callback OAuth2 callback
POST /api/v1/auth/refresh Refresh JWT
Session API
Expose session orchestrator data:
GET /api/v1/sessions List sessions (filterable: tenant, state, date range)
GET /api/v1/sessions/{id} Get session details
GET /api/v1/sessions/{id}/transcript Get session transcript (L1 entries)
GET /api/v1/sessions/active List active sessions across all tenants
DELETE /api/v1/sessions/{id} Force-stop a session
WebSocket endpoint for live monitoring (Phase 2):
WS /api/v1/sessions/{id}/live Stream live transcript + audio stats
Usage API
Expose usage store:
GET /api/v1/usage List usage across tenants (current period)
GET /api/v1/usage/{tenant_id} Get usage for tenant (with period filter)
PUT /api/v1/tenants/{id}/quota Update tenant quota
Provider API (Phase 3)
GET /api/v1/providers List configured providers (redacted keys)
PUT /api/v1/providers/{slot} Update provider config (llm, stt, tts, etc.)
POST /api/v1/providers/{slot}/test Test provider connectivity
GET /api/v1/providers/registry List available provider implementations
Note: Provider configuration currently lives in the YAML config. Runtime provider swapping requires extending the config system with a database-backed override layer. This is the most complex API extension and is deferred to Phase 3.
UI Pages & Wireframes
1. Dashboard (Home)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Glyphoxa Admin [User] [Logout] β
ββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββββββββββ€
β β β
β Dashboardβ βββββββββββ βββββββββββ βββββββββββ β
β Tenants β βTenants: β βSessions:β β Hours β β
β Campaignsβ β 3 β β 2 live β β 47/100 β β
β NPCs β βββββββββββ βββββββββββ βββββββββββ β
β Sessions β β
β Usage β Active Sessions β
β Providersβ ββββββββββββββββββββββββββββββββββββββββ β
β Users β β luk / Rabenheim β active β 0:42:15 β β
β β β demo / Tutorial β active β 0:05:30 β β
β β ββββββββββββββββββββββββββββββββββββββββ β
β β β
β β Recent Activity β
β β β’ Session ended: luk/Rabenheim (1h 23m) β
β β β’ NPC created: "ErzΓ€hler" in Rabenheim β
β β β’ Tenant "demo" created β
β β β
ββββββββββββ΄ββββββββββββββββββββββββββββββββββββββββββββββββ
Key metrics: Total tenants, active sessions, usage vs. quota bar, system health (provider status from /readyz).
2. Tenant Management
List view: Table with ID, license tier, guilds, campaign, usage bar, actions.
Detail/Edit view:
- Tenant ID (read-only after creation)
- License tier dropdown (shared / dedicated)
- Bot token input (masked, with βTest Connectionβ button)
- Guild IDs (multi-select with Discord guild name resolution)
- DM Role ID
- Campaign assignment (dropdown from campaigns table)
- Monthly session hours quota (number input)
- Danger zone: delete tenant
3. Campaign Management
List view: Cards per campaign with name, game system, NPC count, last session date.
Detail/Edit view:
- Campaign name, game system (dropdown: D&D 5e, Pathfinder 2e, custom)
- Description (markdown editor)
- NPC list (inline, linked to NPC management)
- Entity import: upload YAML entity files or Foundry/Roll20 JSON
- Session history (linked to session monitoring)
- Knowledge graph explorer (Phase 3): visual graph of entities + relationships
4. NPC Management
List view: Cards with NPC name, avatar/icon, engine badge, voice provider badge.
Detail/Edit view:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β NPC: Heinrich der WΓ€chter [Save] β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β β
β Name: [Heinrich der WΓ€chter ] β
β Campaign: [Die Chroniken von Rabenheim βΌ] β
β β
β ββ Personality ββ β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Ein strenger aber gerechter StadtwΓ€chter... β β
β β (multi-line text area) β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β ββ Voice ββ β
β Provider: [ElevenLabs βΌ] β
β Voice ID: [Helmut ] [βΆ Preview] β
β Pitch: [-2.0 βββββββββ +2.0] β
β Speed: [0.5 βββββββββββ 2.0] β
β β
β ββ Engine ββ β
β Type: (β) Cascaded ( ) S2S ( ) Sentence Cascade β
β Budget Tier: ( ) Fast (β) Standard ( ) Deep β
β β
β ββ Knowledge ββ β
β Scope: [Rabenheim history] [guard duties] [+ Add] β
β Secrets: [The mayor's corruption] [+ Add] β
β β
β ββ Behavior Rules ββ β
β β’ Spricht immer Deutsch [β] β
β β’ Misstraut Fremden zunΓ€chst [β] β
β [+ Add Rule] β
β β
β ββ Advanced ββ β
β MCP Tools: [patrol_route] [check_papers] [+ Add] β
β GM Helper: [ ] Address Only: [β] β
β Attributes: { "alignment": "lawful neutral" } β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Voice preview: POST to /api/v1/npcs/{id}/voice-preview with a sample sentence β returns audio blob β play via Web Audio API. This calls the TTS provider with the NPCβs voice config.
5. Session Monitoring
Active sessions list:
- Real-time updating (polling every 5s, or WebSocket in Phase 2)
- Per session: tenant, campaign, guild, channel, state, duration, worker pod
- Actions: force-stop
Session detail view:
- Session metadata (ID, times, worker, state)
- Live transcript (scrolling log of speaker β text entries)
- Audio stats: VAD activity, STT latency, TTS queue depth (from worker metrics)
- NPC activity: which NPCs responded, response times
Session history:
- Filterable table: date range, tenant, campaign, state
- Per session: duration, transcript entry count, error (if failed)
- Click through to transcript viewer
Transcript viewer:
- Chronological display with speaker avatars/names
- Color-coded: player utterances vs NPC responses
- Raw vs corrected text toggle
- Search within transcript (full-text search via existing GIN index)
- Export as text/JSON
6. Usage & Billing
Overview:
- Per-tenant usage cards: session hours (bar chart vs quota), LLM tokens, STT seconds, TTS characters
- Time period selector (current month, previous months)
Detail view per tenant:
- Line chart: daily session hours over the billing period
- Breakdown table: per-session usage (duration, tokens, STT time, TTS chars)
- Quota management: edit
monthly_session_hours - Export as CSV
7. Provider Configuration (Phase 3)
Provider slots grid:
ββββββββββββ ββββββββββββ ββββββββββββ
β LLM β β STT β β TTS β
β OpenAI β β Deepgram β β Eleven β
β gpt-4o β β nova-2 β β Labs v2 β
β β β β β β β β β
ββββββββββββ ββββββββββββ ββββββββββββ
ββββββββββββ ββββββββββββ ββββββββββββ
β VAD β β S2S β βEmbeddingsβ
β Silero β β (none) β β Gemini β
β v5 β β β β emb-001 β
β β β β β β β β β
ββββββββββββ ββββββββββββ ββββββββββββ
Each card: provider name, model, status indicator, latency P50/P99. Click to edit: API key (masked), base URL, model, provider-specific options. βTest Connectionβ button per provider.
8. User Management (Phase 2)
User list: Table with name, Discord username, role, tenant, last active.
Invite flow: Generate invite link or add by Discord ID β assign role.
Deployment Strategy
Development
glyphoxa/
βββ web/ # SPA source (gitignored build output)
β βββ package.json
β βββ vite.config.ts
β βββ src/
β β βββ main.tsx
β β βββ api/ # Generated API client (from OpenAPI spec)
β β βββ components/ # shadcn/ui components
β β βββ pages/ # Route-level components
β β βββ hooks/ # TanStack Query hooks
β β βββ lib/ # Utilities
β βββ dist/ # Build output (embedded into Go)
βββ internal/gateway/
β βββ admin.go # Extended with new routes
β βββ admin_campaigns.go # Campaign handlers
β βββ admin_npcs.go # NPC handlers
β βββ admin_sessions.go # Session query handlers
β βββ admin_usage.go # Usage handlers
β βββ admin_users.go # User handlers (Phase 2)
β βββ admin_providers.go # Provider handlers (Phase 3)
β βββ webui.go # embed.FS + SPA fallback handler
Embedding:
// internal/gateway/webui.go
package gateway
import "embed"
//go:embed all:web/dist
var webAssets embed.FS
func (a *AdminAPI) registerWebUI() {
// Serve static files, fallback to index.html for SPA routing
a.mux.Handle("GET /", spaHandler(webAssets))
}
Dev workflow:
cd web && npm run devβ Vite dev server on :5173vite.config.tsproxies/api/*to gateway on :8081- Hot module replacement for instant frontend iteration
Production (K3s)
No changes to the existing deployment β the SPA is baked into the gateway binary.
# Multi-stage Dockerfile addition
FROM node:22-alpine AS frontend
WORKDIR /app/web
COPY web/package*.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM golang:1.26 AS backend
# ... existing build steps ...
COPY --from=frontend /app/web/dist ./web/dist
RUN go build -o /glyphoxa ./cmd/glyphoxa
Makefile additions
web-install:
cd web && npm ci
web-dev:
cd web && npm run dev
web-build:
cd web && npm run build
web-lint:
cd web && npm run lint
build: web-build # Add web-build as dependency to existing build target
Phase Breakdown
Phase 1: MVP (Foundation + Tenant/Campaign/NPC CRUD)
Scope: Get a working UI for the most common operations.
Backend:
- Create
campaignstable +CampaignStore(PostgreSQL) - Add campaign CRUD endpoints (
/api/v1/campaigns) - Expose NPC store via HTTP (
/api/v1/campaigns/{id}/npcs,/api/v1/npcs/{id}) - Add session list/detail endpoints (
/api/v1/sessions) - Add usage query endpoint (
/api/v1/usage/{tenant_id}) - Add NPC voice preview endpoint (
POST /api/v1/npcs/{id}/voice-preview) - SPA embedding infrastructure (
embed.FS, SPA fallback handler) - OpenAPI spec generation (for typed API client)
Frontend:
- Vite + React + TypeScript + Tailwind + shadcn/ui scaffolding
- API key login page (stores key in HttpOnly cookie)
- Dashboard with metric cards and active sessions list
- Tenant list + create/edit/delete
- Campaign list + create/edit/delete
- NPC list + create/edit with voice preview
- Session list with transcript viewer
- Basic usage display per tenant
Auth: Existing API key mechanism. Single admin role.
Estimated scope: ~15-20 new Go files, ~30-40 React components, 1 new DB migration.
Phase 2: User Auth + Live Monitoring
Scope: Multi-user access, Discord OAuth2, live session monitoring.
Backend:
userstable + user CRUD endpoints- Discord OAuth2 flow (authorize, callback, JWT issuance)
- JWT auth middleware (alongside existing API key auth)
- Role-based access control middleware
- WebSocket endpoint for live session transcript streaming
- Session audio stats endpoint (from worker metrics)
Frontend:
- Discord OAuth2 login flow
- User management page (invite, role assignment)
- Role-based navigation (hide pages user canβt access)
- Live session monitoring with WebSocket transcript stream
- Session audio stats visualization (latency charts)
Estimated scope: ~10 new Go files, ~15-20 React components, 1 new DB migration.
Phase 3: Provider Config + Advanced Features
Scope: Runtime provider management, knowledge graph explorer, advanced billing.
Backend:
- Database-backed provider config override layer
- Provider CRUD endpoints with connectivity testing
- Provider health/latency metrics endpoint
- Knowledge graph query API (entities + relationships)
- Usage export endpoint (CSV)
- Audit log table + endpoints
Frontend:
- Provider configuration page with test buttons
- Knowledge graph visualization (force-directed graph, e.g., D3 or react-force-graph)
- Usage export/download
- Audit log viewer
- Campaign entity/relationship browser
Estimated scope: ~8-12 new Go files, ~15-20 React components, 2 new DB migrations.
Key Design Decisions & Trade-offs
1. SPA vs Server-Rendered
Decision: SPA (React). Why: Voice preview (Web Audio), live session monitoring (WebSocket), rich NPC editing (tag inputs, sliders, drag-and-drop) all require significant client-side JS. An SPA also enables offline-capable editing and optimistic updates via TanStack Query.
2. Embedded vs External Service
Decision: Embedded in gateway binary. Why: Single binary deployment, no CORS, shared auth, minimal ops overhead. The embed.FS approach means zero runtime dependencies for serving the UI.
3. API Key First, OAuth2 Later
Decision: Ship Phase 1 with API key auth, add Discord OAuth2 in Phase 2. Why: API key auth already works and is secure for single-operator use. OAuth2 adds significant complexity (token refresh, session management, Discord API integration) that shouldnβt block the MVP.
4. OpenAPI Spec as Contract
Generate an OpenAPI 3.1 spec from Go struct tags + route definitions. Use oapi-codegen or similar to generate a TypeScript API client. This keeps frontend and backend type-safe without manual sync.
5. Campaign as First-Class Entity
Currently, campaign_id is just a string field on tenants and NPCs. Promoting campaigns to a proper table with metadata enables:
- Multiple campaigns per tenant (dedicated tier)
- Campaign-level settings (game system, description)
- Clean foreign key relationships
- Campaign-scoped NPC listing in the UI
6. Tenant Isolation in the UI
The UI must respect tenant boundaries. In Phase 1 (API key = super admin), all data is visible. In Phase 2 (user auth), the backend filters all queries by the userβs tenant_id β the frontend never sees cross-tenant data.
Security Considerations
- Bot tokens: Never returned in API responses (existing behavior). UI shows ββ’β’β’β’β’β’β’β’β with a βChangeβ button.
- API keys: Stored in Vault Transit (existing infrastructure).
- CSRF: SameSite=Strict cookies + custom header requirement.
- XSS: Reactβs default escaping + CSP headers. No
dangerouslySetInnerHTML. - Rate limiting: Apply existing NPM rate limiting or add Go-side rate limiter.
- Input validation: Server-side validation on all endpoints (existing pattern with
Validate()methods). Client-side validation is UX only. - Audit logging (Phase 3): All write operations logged with user, timestamp, and change diff.
Open Questions
- Voice preview cost: TTS API calls for previews cost money. Rate-limit to N previews per minute? Cache common samples?
- Multi-campaign on shared tier? Currently shared tier = 1 session at a time. Should shared tenants be limited to 1 campaign, or can they have multiple (but only run 1 session)?
- NPC image/avatar upload? Nice for the UI but adds blob storage complexity. Defer to Phase 3? Or use Discord avatar URLs?
- Localization? Glyphoxa is used in German (Rabenheim campaign). Should the UI support i18n from the start? Recommend English-only MVP with i18n hooks (react-i18next) ready for Phase 2.
- Mobile responsive? DMs might manage NPCs from a phone during sessions. Tailwind makes responsive easy β design mobile-first from Phase 1.