Chiti

A multi-tenant customer-support platform. A chat widget embeds on a shop's own site, and Messenger, Instagram, WhatsApp, Telegram and TikTok land in the same queue beside it. A bot answers first from the shop's knowledge and hands over to a person with a briefing; the operator works the queue with an AI copilot at their elbow; admins and a read-only supervisor run the team; a platform console runs the tenants. The whole product speaks Georgian and English, and the shop's own content carries Russian too. We built it end to end — a Node API with one socket namespace per tenant, and a React front end that carries every console and the widget.

Year: 2026 · Category: customer support

Our role

Full-stack. We owned the multi-tenant data model, the channel rules and the bot-to-operator hand-over, the realtime layer, the widget, the consoles for all five roles, and the three-language content model.

The problem

A shop that sells to people in Georgia does not get questions in one place. They arrive on its website, in Messenger, on Instagram, on WhatsApp, on Telegram and on TikTok, and each of those is a separate inbox with a separate person watching it. Nobody can say how long a customer has been waiting, a conversation that moves between people loses its history on the way, and a manager has no way to see the desk without interrupting it.

The channels also disagree about what a reply is allowed to be. WhatsApp closes the free-form window after a day and then only accepts an approved template. TikTok stops accepting business messages after ten in a row. Instagram will not take a PDF and Telegram has no reactions. An agent who does not know this finds out when a message fails, and a bot that answers on a channel where it is not allowed to is worse.

The approach

One conversation model, and the rules of each channel kept in a single place that everything asks.

Every inbound message is normalised into the same shape whatever it came from, so the queue, the thread, the history and the statistics never branch on channel. The per-channel rules — reply window, message cap, which media are allowed, whether the bot may speak — live in one pure module that the operator's composer and the server's send path both read, so the screen cannot offer what the server will refuse.

The bot answers first and gives up on purpose: a customer who asks for a person, sends something it cannot read, misses twice, or arrives while the AI is down is handed over with a short briefing in the customer's own language. From there the operator has a copilot that drafts grounded replies with citations, a quick-reply library, internal notes, the customer's previous chats, and team chat. Around that sit the admin's controls — routing strategy, timers, working hours, bans, a FAQ flow editor — and a platform console that creates, disables and erases tenants.

Highlights

  • One queue for six channels. A normalised message shape means the inbox, the thread, the history and the statistics never branch on where a message came from.
  • The channel rulebook is code, not convention: a 24-hour WhatsApp window with a template remedy, a seven-day window on Messenger and Instagram, TikTok's ten-in-a-row cap, and where the bot may speak. One pure module answers it for the composer and for the send path.
  • Bot first, person second, on purpose. A request for a human, an attachment the bot cannot read, two misses or an AI outage each hand the chat over with a briefing in the customer's language.
  • The operator's copilot drafts grounded replies with citations, can make them shorter, warmer, formal or translate them between Georgian, English and Russian, and warns when a draft promises something worth double-checking.
  • Multi-tenant to the row. Every record carries a tenant id, every query is scoped by it, each tenant has its own socket namespace, and the platform console manages the lifecycle from creation to erasure.
  • Five roles over three consoles. The supervisor is the admin console with every write taken away, and the platform admin can log in as any member to see what they see.
  • Tenant content is trilingual: FAQ, greetings, quick replies, field labels and closed-hours messages are stored in Georgian, English and Russian, and the widget picks the customer's language.

Architecture

  • Widget: A separate library build of the customer chat, mounted into the host page, themed through CSS variables and scoped under an id prefix so the shop's own styles cannot reach it. A FAQ decision tree, a pre-chat form built from the tenant's custom fields, bot-first chat, typing and read receipts, an inactivity warning and a rating. On a phone the card becomes a bottom sheet.
  • Consoles: One React single-page app carries the operator inbox, the admin and read-only supervisor console and the platform console, routed by role. Server state lives in TanStack Query, forms in React Hook Form with Zod, and the realtime events invalidate the same query keys the screens already read.
  • API: Node and TypeScript on Express. REST mounts for auth, admin, platform, sessions, uploads, integrations, staff chat, comments, AI, push and operator, plus webhook routes that keep the raw body so every provider signature can be verified.
  • Realtime: Socket.IO with one namespace per tenant and rooms for operators, admins, each session and each operator. Queue changes, messages, typing, read receipts, presence and settings updates all travel as typed events, so a theme saved by an admin reaches the widget on a customer's phone.
  • Channel adapters: An adapter per channel turns a provider webhook into one normalised message and declares what the channel can do: number prefix, comments, reactions, history, templates, reply window. A conversation router ingests under a per-contact queue, finds or creates the session, stores media, starts the AI, emits to the room and auto-assigns.
  • AI layer: A separate Python service reached through one client: an answer endpoint for the bot, a turns endpoint for copilot drafts, plus regenerate and feedback. A per-tenant circuit breaker and pause mean an outage turns into an immediate, explained hand-over to a person rather than a chat that hangs.
  • Data: PostgreSQL through Prisma. One database for all tenants, every row carrying a tenant id and every query scoped by it. Sessions, messages, assignments, notes, feedback, custom fields, bans, templates, integrations, staff chat and a full activity log.
  • Background work: In-process timers and sweeps: the inactivity ladder, auto-assignment, operator response timeout, logout grace, a stale-waiting sweep, presence reconciliation after a restart, system-health sampling and retention cleanup.
  • Auth and tenancy: Tokens issued per role behind a role guard. Admins, supervisors and the platform admin share one login, and the platform console can log in as any member of a tenant. Channel feature flags decide which channels a tenant sees.
  • Storage, push and delivery: Uploads on local disk or an S3-compatible driver, web push over VAPID with a service worker, and two ways to ship: Docker Compose (database, API, nginx-served front end) or timestamped release directories behind an atomic symlink flip with PM2 and nginx in front.

In numbers

Roles: 5 · Channels in one queue: 6 · Mocked API routes: 149 · Screens: 25 · Seeded conversations: 369 · Front-end lines: 34k

Results

  • A customer on any of six channels lands in the same queue, in the same thread, with the same wait timer, and the operator sees the rules of that channel before they type rather than after a message fails.
  • A chat that leaves the bot arrives with a briefing — why it was handed over, what the customer wants — so the person who picks it up does not start by asking the question again.
  • An admin can watch any live chat, step into it, end it or clear the queue, and every one of those actions is in an activity log with an actor and a time.
  • A new shop is a row, not a deploy: the platform console creates a tenant, switches its channels on, can disable it, delete it, bring it back, or erase it for good.
  • The same product reads in Georgian and English, and a shop's FAQ, greetings, quick replies and field labels are stored in Georgian, English and Russian side by side.

What is used

  • Back end: Node.js, TypeScript 5, Express 4, Socket.IO 4, Zod 3, Helmet, p-queue 9, Multer, Sharp, bcryptjs, web-push 3
  • Data and storage: PostgreSQL, Prisma 6, AWS SDK v3 (S3-compatible uploads), Local-disk upload driver
  • AI: FastAPI (separate Python service), Per-tenant circuit breaker, Retrieval-grounded answers with citations
  • Front end: React 18, TypeScript 5, Vite 5, Tailwind CSS 3, shadcn/ui on Radix UI, Ant Design 6, TanStack Query 5, React Flow 12, React Hook Form 7 + Zod 3, React Router 6, i18next 25 (KA/EN/RU), Recharts 2, socket.io-client 4, Vitest 2
  • Runtime and delivery: Docker Compose, nginx, PM2, Atomic release directories, Web push (VAPID, service worker)
  • Demo build: Vite 6, React 18, Tailwind CSS 4, @portfolio/demo-kit (mock API, store, session), TanStack Query 5, React Router 6, i18next 24 (KA/EN), Radix UI, Recharts 2, cmdk, Sonner, React Hook Form 7 + Zod 3, BroadcastChannel, Fontsource (Bricolage Grotesque, Instrument Sans, Noto Sans Georgian, IBM Plex Mono)

The demo

A fictional re-skin of a real product we built: the original's name, identity, copy and data are gone, replaced by a made-up Georgian brand and a wine shop called Vazi as the demo tenant. The back end is replaced by an in-browser mock — seed data compiled into the bundle, 149 routes answered from memory, and scripted customers, bot, copilot and channel handshakes. The bar in the corner switches between five accounts, or open the live desk to see the widget and the operator inbox side by side. Nothing is stored: a reload restores the pristine seed, and two open tabs share one live world.