13 KiB
Architecture — Chat Switchboard v0.11
Deployment Modes
Three Docker images support different deployment scenarios:
| Image | Dockerfile | Contents | Use Case |
|---|---|---|---|
| Unified | Dockerfile |
nginx + Go backend | Dev, docker-compose, single-node |
| Backend | server/Dockerfile |
Go binary only | K8s — scale API independently |
| Frontend | Dockerfile.frontend |
nginx + static files | K8s — scale FE independently |
Unified bundles everything in one container. Nginx serves static files and proxies /api/* and /ws to the Go backend running on :8080. Good for development and small deployments.
Split (Backend + Frontend) for production K8s: Ingress routes /api/* and /ws to the backend Service, everything else to the frontend Service. The frontend entrypoint (docker-entrypoint-fe.sh) handles BASE_PATH injection into index.html and dynamic nginx config generation at startup. Supports branding volume mounts at /branding/.
┌─────────────────────────┐
│ Ingress / Traefik │
│ ├─ /api/* → be-svc:8080 │
│ ├─ /ws → be-svc:8080 │
│ └─ /* → fe-svc:80 │
└─────────────────────────┘
│ │
┌──────────▼──┐ ┌───────▼────────┐
│ Backend │ │ Frontend │
│ (Go :8080) │ │ (nginx :80) │
│ replicas:N │ │ replicas:M │
└──────┬──────┘ └────────────────┘
│
┌──────▼──────┐
│ PostgreSQL │
└─────────────┘
Design Principles
-
Persona-as-Trust-Boundary: A persona (model + config + prompt) is the unit of access control. Users interact with personas, not raw provider configs. Admins control which models are visible; team admins control which personas their team can use.
-
Roles vs Teams: Clean separation between vertical permissions (Roles: admin, user) and horizontal visibility (Teams: organizational units). A user's role determines what they can do; their team membership determines what they can see.
-
Store Layer: All database access goes through typed Go interfaces (
store.Stores). Handlers never write raw SQL. This enables future portability (SQLite for dev, Postgres for prod) and testability (mock stores). -
Scope Model: Provider configs, personas, and model settings all use a three-value
scopecolumn:global(admin-managed, visible to all),team(team-admin-managed, visible to team),personal(user-managed, visible to owner). Theowner_idcolumn points to the owning user or team depending on scope. -
Capabilities Resolution: Model capabilities (vision, tool calling, thinking, context window) are resolved through a priority chain: catalog DB (provider API sync, per-provider authoritative) → heuristic inference (regex patterns on model ID). No static model table — the same model can have different capabilities on different providers. The catalog is populated by auto-fetch on provider creation and manual refresh.
-
Channels as Execution Context: Channels are the universal container for conversation state — messages, tool activity, notes, and artifacts all hang off a channel. Today channels are single-user direct chats. The architecture anticipates multi-participant channels (team members, anonymous visitors, AI personas) for workflow execution, without requiring changes to the message tree, tool framework, or streaming infrastructure.
Workflow Architecture (Future — v0.21.0+)
The platform's existing primitives (teams, personas, channels, tools, notes) compose into a workflow engine where the channel is the execution context that moves through defined stages.
Conceptual Model
Team
└─ Workflow (team-admin defined)
├─ name, description
├─ entry conditions (public link, team-internal, API trigger)
└─ Stages[]
├─ persona_id (which AI drives this stage)
├─ assignment_team_id (who can be assigned)
├─ form_template (structured note schema, optional)
└─ transitions[] (conditions → next stage)
Channel (workflow instance)
├─ workflow_id + current_stage
├─ participants[]
│ ├─ anonymous visitor (mTLS fingerprint / session token)
│ ├─ AI persona (per-stage, from workflow definition)
│ └─ assigned team member (claimed or auto-routed)
├─ messages (existing tree structure)
├─ notes (channel-scoped artifacts, intake forms)
└─ tool activity (existing execution framework)
How Existing Primitives Map
| Existing Primitive | Workflow Role |
|---|---|
| Persona (system prompt + model) | Drives a workflow stage — the AI knows what to collect, when to route |
| Team (members + roles) | Owns the workflow definition; members are assignable to channels |
| Channel (messages + tree) | Execution instance of a workflow; full conversation history |
| Notes + Tools | Structured data collection; the persona's system prompt is the form definition, the note is the filled form |
| EventBus + WebSocket | Real-time notifications for assignment, stage transitions, new messages |
Design Constraints for Current Development
These invariants keep the workflow path open without building it prematurely:
- Channels: Don't assume single-owner. If touching channel queries, keep
room for
team_id,type(beyonddirect), and multi-participant access patterns alongsideuser_id. - Notes: Currently user-scoped. Future channel-scoped notes (attached to
a conversation, not a personal notebook) need a
channel_idFK option. - Personas: Already team-scoped. Don't couple to "user picks from dropdown" — a workflow stage references a persona programmatically.
- Tool ExecutionContext: Already carries
UserID+ChannelID. Will needTeamIDandWorkflowID— the struct is easily extended. - Auth: mTLS anonymous users need identity to participate in channels.
Lightest version: a
participantstable that can reference auser_idor an opaque session identifier (cert fingerprint). Don't assume every channel participant has a row inusers.
Package Structure
server/
├── main.go # Wiring: stores → handlers → routes
├── config/config.go # Env-based configuration
├── database/
│ ├── database.go # Connection management
│ ├── migrate.go # Auto-migration on startup
│ └── migrations/
│ └── 001_v09_schema.sql # Consolidated schema
├── store/
│ ├── interfaces.go # Store interfaces + shared types
│ └── postgres/ # Postgres implementations
│ ├── stores.go # NewStores() constructor
│ ├── provider.go # ProviderStore
│ ├── catalog.go # CatalogStore
│ ├── persona.go # PersonaStore
│ ├── user.go # UserStore
│ ├── team.go # TeamStore
│ ├── policy.go # PolicyStore
│ ├── audit.go # AuditStore
│ └── ...
├── models/models.go # Shared domain types
├── capabilities/
│ ├── intrinsic.go # Heuristic detection + resolution
│ └── resolver.go # ModelsForUser() unified resolver
├── handlers/ # HTTP handlers (Gin)
│ ├── auth.go # Login, register, refresh, logout
│ ├── admin.go # User/config/model management
│ ├── channels.go # Channel CRUD
│ ├── messages.go # Message CRUD + forking
│ ├── completion.go # Chat completions (SSE streaming)
│ ├── capabilities.go # Model list + ResolveModelCaps
│ ├── presets.go # Persona CRUD (all scopes)
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
│ ├── teams.go # Team management
│ └── ...
├── providers/ # LLM provider adapters
│ ├── provider.go # Provider interface
│ ├── anthropic.go
│ ├── openai.go
│ ├── openrouter.go
│ └── venice.go
├── middleware/ # Auth, admin, CORS, rate limiting
├── events/ # EventBus + WebSocket hub
└── tools/ # Built-in tool definitions (notes)
Store Layer Pattern
Every store follows the same pattern:
// Interface in store/interfaces.go
type FooStore interface {
Create(ctx context.Context, f *models.Foo) error
GetByID(ctx context.Context, id string) (*models.Foo, error)
Update(ctx context.Context, id string, patch models.FooPatch) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, opts ListOptions) ([]models.Foo, int, error)
}
// Implementation in store/postgres/foo.go
type fooStore struct{ db *sql.DB }
func (s *fooStore) Create(ctx context.Context, f *models.Foo) error { ... }
Handlers receive store.Stores (a bundle of all store interfaces):
type AdminHandler struct { stores store.Stores }
func (h *AdminHandler) CreateUser(c *gin.Context) {
// ...
err := h.stores.Users.Create(c.Request.Context(), user)
}
Capabilities Resolution Chain
When the system needs to know what a model can do (vision? tools? thinking?):
1. model_catalog DB (exact match: model_id + provider_config_id)
↓ miss
2. model_catalog DB (any provider: same model_id)
↓ miss
3. Heuristic inference (name-based: "gpt-4-vision" → vision=true)
The capabilities.ModelsForUser() function combines catalog entries, team personas, and user preferences into a single unified model list for the frontend.
Scope / Ownership Model
scope='global' → owner_id=NULL → Admin-managed, visible to all
scope='team' → owner_id=team.id → Team-admin-managed, visible to team
scope='personal' → owner_id=user.id → User-managed, visible to owner
Used by: provider_configs, personas, model_catalog (visibility column adds enabled/disabled/team states on top).
Schema Migration
Single consolidated migration (001_v09_schema.sql) replaces the previous 21 incremental migrations. The Go backend auto-migrates on startup:
- Creates
schema_migrationstable if absent - Checks which migration files have been applied
- Applies any new
.sqlfiles in order
Frontend Architecture
Vanilla JavaScript, no build step. Five files with clear responsibilities:
| File | Role |
|---|---|
api.js |
HTTP client with token refresh. All backend calls. |
app.js |
Application state machine. Business logic. |
ui.js |
DOM rendering. All document.createElement calls. |
events.js |
Labeled event bus with WebSocket bridge. |
debug.js |
Admin debug panel (model list, stats, config). |
Communication: app.js calls API.* methods, updates state, then calls UI.* methods to render. events.js handles real-time updates via WebSocket. No framework, no virtual DOM, no reactive bindings.
Security Model
- Auth: JWT access tokens (short-lived) + refresh tokens (DB-stored, revocable)
- Admin Bootstrap:
SWITCHBOARD_ADMIN_USERNAME/PASSWORDenv vars create/update admin on every startup (K8s secret pattern) - API Key Storage: Provider API keys stored in
api_key_enccolumn (TODO: at-rest encryption) - Policies: Boolean flags in
global_settingstable control registration, BYOK, team providers, etc. - Audit: All admin operations logged to
audit_logwith actor, action, resource type/ID, and diff - Banner: Environment classification banner configurable via admin settings (text, color, position)
- CORS: Configurable allowed origins via env var
- No sensitive terminology: Banner system avoids classification-related terms for security compliance
Backward Compatibility
v0.9 maintains backward-compatible API routes:
| Old Route | New Handler | Notes |
|---|---|---|
/api/v1/presets |
PersonaHandler | Returns both personas and presets keys |
/api/v1/api-configs |
ProviderConfigHandler | Unchanged route, new implementation |
/api/v1/models |
ModelHandler.ListEnabledModels | Alias for /models/enabled |
JSON field rename: api_config_id → provider_config_id in channel and completion request/response bodies. Frontend updated to match.