Architecture overview
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Architecture overview
A tour of every service in the Chatly platform, what it owns, how it talks to its neighbors, and the trust boundaries between them.
Chatly is a TypeScript monorepo on Node 20+. The reference production deployment runs fourteen application containers plus Postgres and Redis, and they share state through those two. There is no single monolithic backend; every service can scale, deploy, and fail independently.
This page is the map. Once you've read it, the rest of the docs should make sense — every feature lives in one of the services listed below, and the data flows between them follow a small handful of patterns.
Info — BYOK at the platform boundary
Every external integration is BYOK at the workspace level. No telco, no SMTP, no LLM provider key is hard-coded in our infra. Each customer's keys live encrypted in their workspace and are decrypted only at call time. See Credential ownership.
Services at a glance
┌──────────┐ ┌──────────┐ ┌──────────┐
│dashboard │ │help-center│ │marketing │ ─── public web tier
└──────┬───┘ └─────┬────┘ └──────────┘
│ │
▼ ▼
┌─────────────────────────────────────┐
│ api │ ─── REST /v1/* only
└─┬───────────┬─────────────┬──────────┘
│ │ │
▼ ▼ ▼
┌─────┐ ┌────────┐ ┌────────┐
│ai │ │realtime│ │worker │ ─── service tier
└──┬──┘ └────┬───┘ └────┬───┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────┐
│ Postgres + Redis │ ─── data plane
└─────────────────────────────────────┘
│
├── livekit + livekit-sip ─── voice plane
│
└── widget served by widget-host ─── visitor surfaceService responsibilities
Production services (the containers in docker-compose.prod.yml)
Name | Type | Description |
|---|---|---|
|
| REST |
|
| Pushes events to dashboard + widget. Listens on |
|
| Triggers, workflows, webhook deliveries, importers, AI tasks, voice events, surveys, retention sweeps, push notifications. |
|
| LLM broker + RAG + classifiers + cost metering. Provider-agnostic via the |
|
| LiveKit token mint, Twilio webhook bridge, recording/transcription orchestration. |
|
| Pure client-side SPA. No SSR. |
|
| Multi-tenant KB renderer. One process serves every workspace under its custom domain. |
|
| Public site + this docs site. |
|
| Customer-facing ticket portal ( |
|
| Serves the built widget bundle and loader from a CDN-like nginx container. The widget itself is built from |
|
| Holds the persistent Discord gateway socket, which a request-scoped API process cannot. |
|
| MTProto client for the |
|
| Plus |
|
| Bridges PSTN (Twilio, Telnyx) ↔ LiveKit rooms. |
Not containers, but in the monorepo and shipped separately: apps/widget and apps/widget-loader (build artefacts served by widget-host), apps/mcp, apps/chrome-extension, and apps/mobile-agent.
Warning — There is no GraphQL API
The API serves REST under
/v1/*only. There is no/graphqlendpoint and no GraphQL dependency in the API package. Anything you read elsewhere describing a GraphQL surface is aspirational.
Info — Widget bundle size
The built widget is about 45 KB gzipped against a <50 KB budget — not 30 KB. Shadow-DOM isolated, built standalone.
All services run on a single VPS in the reference deployment via docker-compose.prod.yml, or on Kubernetes via the Helm chart at infra/helm/livechat/. Same containers, same configs — only the orchestrator changes.
Data plane
Stateful components
Name | Type | Description |
|---|---|---|
|
| Every tenant table has Row-Level Security. Application sets |
|
| BullMQ queues, Socket.IO adapter, rate-limit buckets, idempotency keys, presence heartbeats, ephemeral tokens, AI usage counters. |
|
| For RAG embeddings. Without it — or without a workspace LLM key, since embedding is a paid call — nothing is indexed and retrieval falls back to Postgres FTS. |
|
| Generated |
|
| Attachments + voice recordings + import payloads. MinIO for self-host; AWS S3/Cloudflare R2/Backblaze B2/Wasabi for Cloud. Customers can BYOK their own bucket for full data residency. |
Event flow
Every domain change emits an event. The pattern is durable-then- deliver:
1. API receives request → validates → BEGIN tx (workspace GUC set)
2. Writes domain change to Postgres (within RLS-scoped tx)
3. COMMIT
4. Publishes an envelope to Redis, in one pipeline:
XADD events:{workspaceId} ← durable replay log, 24h TTL
PUBLISH realtime:{workspaceId} ← live fan-out
5. Realtime fans out to subscribed sockets
6. Worker picks up webhook deliveries, trigger evaluation, workflow runsPostgres is the source of truth and is written first — persist, then deliver. Redis carries the delivery.
Warning — There is no outbox table and no outbox relay
The publish in step 4 happens after the commit, not inside it, and there is no transactional outbox row to replay from. A process that dies between the commit and the publish loses that event's notification — the durable state is already in Postgres and a client that refetches sees it, but a client relying purely on the socket will not be told. The Redis stream gives you a 24-hour replay window (
lastEventId) for the far more common case: a socket that dropped and reconnected.
The envelope is versioned and identical on both paths:
{ "v": 1, "type": "message.created", "id": "…", "ts": 1740000000000,
"workspace_id": "…", "payload": { } }Realtime protocol
The realtime service is a Socket.IO server on port 4001 with the Redis adapter, so any pod can serve any socket.
Parameters
Name | Type | Description |
|---|---|---|
|
|
|
|
| Every envelope arrives on the socket event named |
|
| Socket.IO |
|
| camelCase, sent on subscribe. The server replays from the workspace's Redis stream and then emits a |
|
| The stream |
|
| Presence is Redis-backed with a grace period (default 20s) before an agent's last closed socket is written offline, so a refresh does not flap them. A sweep marks socket-less memberships offline as a backstop for pods that died with sockets open. |
Trust boundaries
Parameters
Name | Type | Description |
|---|---|---|
|
| Every public endpoint validates with zod. The same schema generates the OpenAPI spec. |
|
| RS256 JWT signed with a platform keypair (PEM in / PEM out), not a per-workspace JWKS. Claims are |
|
| Postgres RLS. Every authenticated transaction runs |
|
| Adapter calls (Twilio, HubSpot, OpenAI) read credentials from the workspace's own encrypted config at the call site, never from |
|
|
|
|
| Agents present the same RS256 JWT, verified at upgrade against a public-key PEM. Visitors present a separate HS256 token minted per conversation, so a visitor credential can never be mistaken for an agent one. Room subscription is gated on the workspace id in the verified claims. |
Danger — A missing workspace GUC returns zero rows, not an error
Under RLS, a query on a connection that never set
app.current_workspace_idmatches nothing and raises nothing. Code that runs as a superuser bypasses the policies entirely and so never notices. Both failure modes look like "empty result", which is why tenant-isolation tests here assert on a scoped connection, never on a superuser one.
Source tree
apps/
├── api/ # NestJS on Fastify — REST /v1/*
├── realtime/ # Socket.IO server
├── worker/ # BullMQ consumer
├── ai/ # LLM broker + RAG + classifiers
├── voice/ # LiveKit + Twilio bridge
├── dashboard/ # React agent dashboard
├── help-center/ # Next.js KB renderer
├── marketing/ # Next.js marketing + docs
├── portal/ # Next.js customer ticket portal
├── widget/ # Preact widget bundle
├── widget-loader/ # Tiny loader script
├── discord-gateway/ # Persistent Discord gateway socket
├── telegram-userbot/ # MTProto client for telegram_user channels
├── mcp/ # Model Context Protocol server
├── chrome-extension/ # Browser extension
└── mobile-agent/ # Mobile agent app
packages/
├── db/ # Drizzle schema + migrations + RLS helpers
├── shared/ # Cross-app types + zod schemas + permissions
├── auth/ # JWT, session, RBAC re-exports
├── events/ # Event bus (Redis stream + pubsub)
├── sdk-js/ # Public browser SDK
├── sdk-node/ # Public server SDK
├── ui/ # Shared React components (shadcn-based)
├── config/ # ESLint, tsconfig, tailwind preset
└── testing/ # Test helpers + factories
infra/
├── terraform/ # Kubernetes + Helm providers (see Deployment shapes)
├── helm/livechat/ # Helm chart
├── docker/ # Dockerfile.{service} + nginx confs
├── observability/ # Grafana dashboard JSON
├── monitoring/ # Synthetic checks
├── livekit/ # LiveKit + SIP configs
└── perf/ # k6 load scriptsThere is no apps/widget-host — widget-host is an nginx container that serves what apps/widget and apps/widget-loader build.
Deployment shapes
Single VPS (reference)
The simplest production deployment: all services on a single VPS via docker-compose.prod.yml, fronted by Traefik with automatic Let's Encrypt TLS. Suitable for workspaces up to ~10,000 active contacts.
Kubernetes (Helm)
The Helm chart at infra/helm/livechat/ covers the backend tier — api, ai, realtime, worker, and discord-gateway — as Deployments, with an Ingress on the API, plus HPA, PodDisruptionBudgets, NetworkPolicies, a Prometheus ServiceMonitor, and ExternalSecrets wiring.
Warning — The chart does not cover everything compose does
There are no chart templates for
dashboard,help-center,marketing,portal,voice,widget-host,livekitorlivekit-sip, and no StatefulSets — Postgres and Redis are expected to be managed services or deployed separately. Budget for that gap when planning a Kubernetes rollout.
infra/terraform is a thin wrapper: it declares the kubernetes and helm providers, creates the namespace, and installs the chart. It provisions no cloud infrastructure of its own.
Observability
The API and the worker both call bootstrapTelemetry at entry, starting an OpenTelemetry NodeSDK with auto-instrumentation for HTTP, pg and ioredis, plus OTLP trace and metric exporters. Every request span carries the same trace ID across services, so a "user clicked send" → "message persisted" → "webhook delivered" flow is a single contiguous trace.
Info — You bring the collector
Telemetry is a deliberate no-op when
OTEL_EXPORTER_OTLP_ENDPOINTis unset, so a self-hosted deployment with no collector still boots. Chatly does not ship a Grafana/Loki/Tempo stack in either the compose file or the Helm chart — what the repo provides is a Grafana dashboard JSON (infra/observability/) and a Prometheus ServiceMonitor in the chart. Point them at your own stack.