API Docs

Architecture overview

AdminUpdated Sep 19, 2026

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 surface

Service responsibilities

Production services (the containers in docker-compose.prod.yml)

Name

Type

Description

api

NestJS on Fastify

REST /v1/*. Auth, validation, RLS scope setup. Per-workspace tenant scoping enforced at the request boundary.

realtime

Socket.IO + Redis adapter

Pushes events to dashboard + widget. Listens on 4001. Reconnect replays via lastEventId.

worker

BullMQ consumer

Triggers, workflows, webhook deliveries, importers, AI tasks, voice events, surveys, retention sweeps, push notifications.

ai

NestJS

LLM broker + RAG + classifiers + cost metering. Provider-agnostic via the LLMProvider interface.

voice

NestJS

LiveKit token mint, Twilio webhook bridge, recording/transcription orchestration.

dashboard

React 18 + Vite

Pure client-side SPA. No SSR.

help-center

Next.js 14

Multi-tenant KB renderer. One process serves every workspace under its custom domain.

marketing

Next.js 14

Public site + this docs site.

portal

Next.js

Customer-facing ticket portal (/v1/portal/*).

widget-host

nginx

Serves the built widget bundle and loader from a CDN-like nginx container. The widget itself is built from apps/widget; there is no apps/widget-host source directory.

discord-gateway

Node

Holds the persistent Discord gateway socket, which a request-scoped API process cannot.

telegram-userbot

Node

MTProto client for the telegram_user channel. BYOK api_id/api_hash per workspace.

livekit

self-hosted WebRTC SFU

Plus coturn for STUN/TURN.

livekit-sip

SIP gateway

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 /graphql endpoint 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

Postgres 16+

primary store

Every tenant table has Row-Level Security. Application sets app.current_workspace_id on every authenticated transaction. RLS policies enforce workspace_id = current_setting('app.current_workspace_id')::uuid. Two locks, one key.

Redis 7+

cache + queues + pubsub

BullMQ queues, Socket.IO adapter, rate-limit buckets, idempotency keys, presence heartbeats, ephemeral tokens, AI usage counters.

pgvector

optional

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.

Postgres FTS

always-on

Generated tsvector columns with GIN indexes over KB articles, conversations, contacts. Ranked with Postgres ts_rank — this is not BM25, and relevance ordering differs from an OpenSearch-style engine.

S3-compatible storage

optional

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 runs

Postgres 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

Envelope

versioned

{ v, type, id, ts, workspace_id, payload }. Snake_case workspace_id, milliseconds in ts, and a UUID id that doubles as the idempotency key.

Transport event

string

Every envelope arrives on the socket event named event. Switch on type, not on the socket event name.

Heartbeat

25s / 50s

Socket.IO pingInterval 25,000 ms, pingTimeout 50,000 ms.

Replay cursor

lastEventId

camelCase, sent on subscribe. The server replays from the workspace's Redis stream and then emits a replay.completed envelope so a client can tell replay from live traffic. A failed replay emits an error envelope rather than silently delivering nothing.

Replay window

24 hours

The stream events:{workspaceId} is length-capped and expires after 24h. A client offline longer than that must refetch over REST — the replay is not a substitute for a sync endpoint.

Presence

Redis + Postgres

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

Input validation

API boundary

Every public endpoint validates with zod. The same schema generates the OpenAPI spec.

Auth

API

RS256 JWT signed with a platform keypair (PEM in / PEM out), not a per-workspace JWKS. Claims are iss, sub (user id), jti, iat, exp, and a mem object carrying workspace_id, membership_id and a single role. There is no roles[] array and no perms[] claim — permissions are resolved server-side from the membership on every request, so revoking one takes effect immediately rather than at token expiry.

Tenant scoping

DB layer

Postgres RLS. Every authenticated transaction runs set_config('app.current_workspace_id', …, true) and every tenant policy tests against current_setting('app.current_workspace_id').

Credentials

per-call

Adapter calls (Twilio, HubSpot, OpenAI) read credentials from the workspace's own encrypted config at the call site, never from process.env — the platform has no defaults.

Audit log

append-only

UPDATE and DELETE are revoked on audit_log for the application role. Note it is the one tenant table with no RLS policy — a deliberate, documented gap. See RBAC.

Realtime auth

WS handshake

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_id matches 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 scripts

There is no apps/widget-hostwidget-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 tierapi, 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, livekit or livekit-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_ENDPOINT is 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.

Was this page helpful?