When to use it
When a team needs its own communication service instead of renting someone else's: a corporate messenger with data stored in-house, telemedicine with video consultations, online education with lesson rooms, a private community with join-by-link calls. The output is a self-hosted counterpart to Slack on the text side and Google Meet on the calls side: a monorepo with a Node.js + TypeScript backend, a React frontend and a docker-compose that brings up PostgreSQL, Redis, the LiveKit media server, Egress for recording and nginx alongside the app.
This isn't "build me a little chat app" — it's a full 18.8 KB spec: the stack is pinned down to specific versions, the database schema is laid out column by column, every REST route, socket event and even the buttons on the call's bottom bar are listed. The agent's job is to execute, not to guess — and that's the whole point: the less freedom at architectural forks, the more predictable the result. How this way of working is set up is covered in the article on agentic coding.
The prompt (copy and paste)
Hand the whole prompt to an agentic tool — Claude Code, Cursor in agent mode, or Codex — in an empty folder of a new project. The prompt is self-contained: the agent won't ask clarifying questions; at a fork it picks the option described and records the decision in the README.
You are a senior full-stack engineer. Build from scratch a production-ready team chat and video-calls service: a Slack analog for the text side and a Telemost/Google Meet analog for calls. Work autonomously until you have a fully working result: create the repository structure, code, database migrations, docker-compose, seed data, and a README with launch commands. Do not ask clarifying questions — at any fork in the road, pick the option described below and record the decision in the README.
=== 1. STACK (use exactly this) ===
Backend: Node.js 20, TypeScript, Express 4, Socket.IO 4, PostgreSQL 16 (pg driver), Redis 7 (cache, presence, rate-limit, @socket.io/redis-adapter adapter), zod for validation, multer for uploads, sharp for thumbnails, helmet, express-rate-limit + rate-limit-redis, pino for logging, jsonwebtoken for sessions, livekit-server-sdk 2.x.
Frontend: React 18, TypeScript, Vite 5, TailwindCSS 3, React Router 6, @tanstack/react-query 5, socket.io-client 4, livekit-client + @livekit/components-react 2, lucide-react for icons, zustand for local call state.
Media: self-hosted LiveKit (SFU) + LiveKit Egress for recording. No hand-rolled SFU, no mesh WebRTC.
Tests: vitest on the frontend, jest + supertest on the backend.
Monorepo structure:
backend/src/{api,sockets,services,repositories,middleware,db/migrations,utils}
frontend/src/{pages,features/chat,components/call,hooks,api,styles}
docker/{docker-compose.yml,livekit/livekit.yaml,livekit/egress.yaml,nginx/nginx.conf}
Code rules: the repository layer owns SQL, services own business logic, routes and socket handlers stay thin. All API responses use the format { success, data } or { success, error }. Every table and every query is filtered by organization_id. No secrets in code — environment variables only.
=== 2. DATABASE ===
Create versioned migrations (up/down) runnable via npm run migrate. In addition to the users and organizations tables (id, name, created_at), create:
chat_channels(id uuid pk, organization_id uuid fk, name varchar(100), slug varchar(100), description text, type varchar(20) check in (public, private, direct), topic text, created_by uuid fk, is_archived bool default false, metadata jsonb, created_at, updated_at, unique(organization_id, slug))
chat_channel_members(channel_id uuid, user_id uuid, role varchar(20) check in (owner, admin, member), joined_at, last_read_message_id uuid, last_read_at, notifications varchar(20) check in (all, mentions, none), is_muted bool, pk(channel_id, user_id))
chat_messages(id uuid pk, channel_id uuid fk cascade, user_id uuid fk, parent_message_id uuid fk null, content text, content_type varchar(20) check in (text, file, system), mentions jsonb default [], edited_at, deleted_at, is_pinned bool, thread_reply_count int default 0, thread_last_reply_at, search_vector tsvector, created_at, updated_at)
chat_message_reactions(message_id uuid, user_id uuid, emoji varchar(50), created_at, pk(message_id, user_id, emoji))
chat_attachments(id uuid pk, message_id uuid, channel_id uuid, file_name, original_name, file_path, file_size bigint, mime_type, thumbnail_path, width int, height int, uploaded_by uuid, created_at)
chat_bookmarks(user_id uuid, message_id uuid, note text, created_at, pk(user_id, message_id))
chat_user_presence(user_id uuid pk, organization_id uuid, status varchar(20) check in (online, away, dnd, offline), custom_status_text varchar(100), custom_status_emoji varchar(50), last_active_at, updated_at)
chat_notifications(id uuid pk, user_id uuid, channel_id uuid, message_id uuid, type varchar(20) check in (mention, dm, thread_reply, channel), is_read bool, created_at)
chat_rooms(id uuid pk, organization_id uuid, created_by uuid, name varchar(255), description text, room_type varchar(20) check in (permanent, one-time), access_level varchar(20) check in (org-only, public), unique_link_token varchar(64) unique, is_active bool default false, call_ended_at, created_at, updated_at, deleted_at)
chat_room_messages(id uuid pk, room_id uuid, user_id uuid null, guest_session_id uuid null, content text, created_at, check(user_id is not null or guest_session_id is not null))
chat_call_rooms(id uuid pk, organization_id uuid, channel_id uuid null, chat_room_id uuid null, livekit_room_name varchar(100) unique, title varchar(200), type varchar(20) check in (audio, video), call_mode varchar(16) default standard check in (economy, standard, conference), created_by uuid, started_at, ended_at, max_participants int default 100, recording_enabled bool default false, recording_url text, metadata jsonb)
chat_call_participants(id uuid pk, room_id uuid, user_id uuid, joined_at, left_at, participant_sid varchar(100))
chat_guest_invites(id uuid pk, organization_id uuid, channel_id uuid null, call_room_id uuid null, chat_room_id uuid null, token varchar(64) unique, guest_name varchar(100), created_by uuid, permissions jsonb default {"canChat":true,"canCall":true,"canViewHistory":false}, max_uses int default 1, used_count int default 0, expires_at, is_active bool default true, created_at, check(at least one of the three targets is not null))
chat_guest_sessions(id uuid pk, invite_id uuid, organization_id uuid, guest_name varchar(100), guest_token varchar(128) unique, channel_id uuid null, call_room_id uuid null, joined_at, last_active_at, left_at, metadata jsonb)
Indexes: (channel_id, created_at desc) on messages, GIN on search_vector, GIN on mentions, partial indexes on token for invites, sessions, and rooms, (organization_id) where deleted_at is null on rooms, (room_id, created_at) on room messages.
Full-text search trigger: tsvector_update_trigger(search_vector, pg_catalog.russian, content) on insert/update of content (swap for pg_catalog.english if your product is English-only).
=== 3. BACKEND: REST API ===
All routes live under /api/chat; all require authentication except the public room routes and guest join.
/channels: POST / (create), GET / (my channels with unread counts), GET /:id, PUT /:id, DELETE /:id (archive), GET /:id/members, POST /:id/members, DELETE /:id/members/:userId, POST /:id/read, PUT /:id/notifications
/messages: GET /channel/:channelId (cursor pagination by created_at), POST /, PUT /:id (author only, sets edited_at), DELETE /:id (soft: deleted_at), GET /:id/thread, POST /:id/reactions, DELETE /:id/reactions/:emoji, POST /:id/pin, GET /search?q= (full-text across the user's channels)
/dm: POST / (open or create a conversation with a user idempotently), GET /
/attachments: POST /upload (multipart, 50 MB limit, mime whitelist, thumbnails for images via sharp), GET /:id, GET /:id/thumbnail, GET /channel/:channelId, DELETE /:id
/calls: POST /start (creates the LiveKit room and the call record, returns a token and URL), POST /:roomId/join, POST /:roomId/leave, POST /:roomId/end, POST /force-leave, POST /:roomId/kick/:userId, POST /:roomId/mute/:userId, POST /:roomId/disable-video/:userId, PATCH /:roomId/mode, GET /active, GET /history, GET /:roomId/participants, POST /:roomId/recording/start, POST /:roomId/recording/stop, GET /:roomId/recording/status, GET /recordings, GET /recordings/:filename, POST /anonymous-token
/rooms: POST /, GET /, GET /:id, PATCH /:id, DELETE /:id, POST /:id/join, GET /:id/messages, POST /:id/messages, GET /:id/active-call, POST /:id/end-call, GET /public/:token, POST /public/:token/join, POST /public/:token/messages
/guests: POST /invites, GET /invites, DELETE /invites/:id, POST /join/:token, GET /session, GET /sessions
misc: POST /bookmarks, DELETE /bookmarks/:messageId, GET /bookmarks, GET /notifications, POST /notifications/read, GET /presence, POST /presence/status
=== 4. BACKEND: SOCKET.IO ===
Authentication in the connection middleware: a user JWT, or a guest token, or an anonymous token of a public room. Three roles: user, guest, anonymous.
Socket rooms: chat:{channelId}, call:{roomId}, chatroom:{roomId}, user:{userId}, presence:{orgId}.
From the client: chat:joinChannel, chat:leaveChannel, chat:sendMessage, chat:editMessage, chat:deleteMessage, chat:addReaction, chat:removeReaction, chat:togglePin, chat:typingStart, chat:typingStop, chat:markRead, chat:openDM, call:start, call:join, call:leave, call:end, call:kick_participant, call:mute_participant, call:disable_video, personal_call:initiate, personal_call:accepted, personal_call:rejected, personal_call:cancelled, presence:online, presence:away, presence:dnd, presence:setCustomStatus, presence:heartbeat, chatroom:subscribe, guest:auth, guest:sendMessage, guest:joinCall, guest:leaveCall
From the server: chat:newMessage, chat:messageEdited, chat:messageDeleted, chat:reactionAdded, chat:reactionRemoved, chat:typing, chat:read, chat:channelUpdated, call:started, call:joined, call:incoming, call:active, call:participantJoined, call:participantLeft, call:participantKicked, call:kicked, call:participant_muted, call:participant_video_changed, call:forceEnd, call:ended, call:already_in_call, call:degradation_changed, call:error, personal_call:incoming, personal_call:timeout, room:statusChanged, room:participantJoined, room:participantLeft, room:callEnded, room:newMessage, presence:changed
Validate every event payload with a zod schema. Rate-limit per event and per role via Redis: chat:sendMessage 10/10s (guest 5), chat:typing* 30/10s, reactions 20/10s, chat:markRead 60/10s, call:join and call:leave 30/60s, anonymous defaults to 0 (forbidden) except for call events inside a public room.
=== 5. LIVEKIT INTEGRATION ===
A livekitService on the backend:
- generateToken(roomName, identity, name, canPublish, canSubscribe, callMode): an AccessToken with grant { roomJoin: true, room, canPublish, canSubscribe, canPublishData: true }; for economy mode add canPublishSources: [microphone, screen_share, screen_share_audio] so the camera is forbidden at the token level, not in the UI. Token TTL: 6 hours.
- createRoom / deleteRoom / listParticipants / removeParticipant / muteTrack via RoomServiceClient.
- Generate the room name as call-{first 8 chars of org}-{first 8 chars of channel or room}-{timestamp in base36}.
- Load livekit-server-sdk inside try/catch and keep an availability flag: without the SDK the app must still start, and calls must return a clear error.
An egressService: startRecording (RoomCompositeEgress, layout grid, preset HD_30, audioOnly for an audio call, output to MP4 on a mounted volume), stopRecording, status, file listing, file serving with an access check. Keep the identifiers of active recordings in Redis, not in process memory.
A background task every 30 seconds: poll the media server's load and, when thresholds are exceeded, broadcast call:degradation_changed to call rooms with a level of none / moderate / high. On moderate the client lowers bitrate and frame rate; on high it turns cameras off and shows a banner.
The client connects via LiveKitRoom from @livekit/components-react with adaptiveStream and dynacast; in standard mode video is rendered for at most 6 participants, the rest are shown as avatars with active-speaker highlighting.
=== 6. FRONTEND: SCREENS ===
Routes: /chat (main window), /chat/:channelId, /rooms (room list), /rooms/:id (room with call and chat), /call/:roomId (full-screen call), /join/:token (public room entry), /guest/:token (guest entry into a channel).
1) Chat window: three columns — sidebar (search, a "New chat" button, channel list with unread counts, DM list with presence status, a "Rooms" block with a live-call badge), message feed (grouping by author, date separators, "typing…", loading older history upward, on message hover — react, reply in thread, pin, bookmark, edit, delete), an on-demand right panel (thread, members, files, links, pinned, search).
2) Composer: auto-growing height, send on Enter and newline on Shift+Enter, attachment button with drag-and-drop and paste from clipboard, emoji picker, @-mentions with a dropdown list, preview of attached files with removal.
3) Pre-call screen: camera preview, dropdowns for camera, microphone, and speaker, camera and microphone toggles, audio level indicator, a name field for guests, a "Join" button.
4) Call screen: dark stage; "grid", "speaker", and "side-by-side" layouts; participant carousel when there are many; draggable self-view; a header with title, timer, and minimize, full-screen, and close buttons; bottom control bar (see below); chat and participants panels slide in from the right; toasts on participants joining and leaving; degradation banner; recording indicator.
5) Rooms: a grid of cards (name, type, access, "Call in progress" badge, participant count) with "Enter", "Copy link", "Settings", and "Delete" buttons; a room-creation modal with permanent/one-time and team-only/by-link choices.
6) Global elements: incoming personal-call modal with ringtone (accept / decline), outgoing-call modal with cancel, a floating minimized-call widget with timer, microphone, hang-up, and expand, toasts for new messages.
Inventory of the call's bottom-bar buttons (implement all of them, with screen-reader labels and hover tooltips):
microphone on/off · camera on/off · screen share · chat (with unread badge) · participants (with counter) · recording start/stop (with "unavailable" and "loading" states) · device settings · "More" (mobile menu: recording, camera switch, settings) · "End" — in a channel call it expands into a "Leave" / "End for everyone" menu.
In the participants list, the host can: mute, disable video, remove from call. All host actions go through the server, not through local state.
=== 7. DESIGN ===
Do not use stock admin-panel templates. Define design tokens in tailwind.config and CSS variables; support light and dark app themes.
Palette: app background is a cool neutral (light theme #EEF0F3, dark #0C0F14), surfaces one tone lighter than the background, text #12161C / #E7ECF3, muted #6A7585 / #808C9C, borders barely visible. One accent only: a signal red-orange (#CE3A20 in light, #FF5A3C in dark) for the active call, recording, and destructive actions. Semantics separate from the accent: online green, away yellow, do-not-disturb red.
The call stage is always dark regardless of the app theme: background #0B0E12, control bar as translucent glass (blur, white fill at 10% opacity), round 48px buttons on mobile and 44px rounded squares on desktop.
Typography: a monospaced font for labels, counters, timers, and technical values; a system grotesque for interface text and messages; sizes 12 / 13 / 15 / 17 / 20 / 24; digits in columns are tabular.
States: every button has normal, hover, pressed, active, disabled, and a visible keyboard focus. Design empty states (no messages, no rooms, nobody in the call) meaningfully, with a hint toward the first action, not as a blank screen.
Responsiveness: three columns on desktop, two on tablet with slide-in panels, one column plus a bottom bar on mobile; touch targets no smaller than 44px; respect prefers-reduced-motion.
=== 8. INFRASTRUCTURE ===
docker-compose with services: app (backend + built frontend), postgres 16, redis 7, livekit (ports 7880, 7881, UDP range 50000-52000), egress (shared Redis, recordings volume), nginx.
livekit.yaml: set rtc.node_ip to the server's public address, use_external_ip: true, tcp_port: 7881, port_range 50000-52000; in the interface list keep only the external interface and exclude the docker bridges — otherwise the container's address ends up in the ICE candidates and no one from outside can connect.
egress.yaml: ws_url pointing at the media server, redis, file_output to /recordings, preset room_composite HD_30.
nginx: WebSocket proxying (Upgrade and Connection headers), client_max_body_size 200m, TLS — without HTTPS the browser will not grant camera and microphone access.
Environment variables: DATABASE_URL, REDIS_URL, JWT_SECRET, LIVEKIT_URL, LIVEKIT_EXTERNAL_URL, LIVEKIT_API_KEY, LIVEKIT_API_SECRET, RECORDINGS_PATH, EGRESS_RECORDINGS_PATH, UPLOAD_PATH, MAX_FILE_SIZE.
Wire the Redis adapter into Socket.IO so the app can scale to multiple instances.
=== 9. SECURITY (mandatory) ===
Every SQL query is filtered by the user's organization_id. Channel access is checked by membership; room access by access level; guest-link access by activity, expiry, and usage limit; the principle is "denied until explicitly allowed". LiveKit keys never reach the client. Publish permissions are set in the token. Uploads are validated by mime and size on the server and served only through an access check. All host actions are re-verified on the server. Write significant actions (message deletion, kicks, recording start) to an audit log. The recording indicator is mandatory and cannot be hidden.
=== 10. BUILD ORDER ===
Stage 1: repository, docker-compose, migrations, authentication, seed of two organizations and five users.
Stage 2: channels, messages, socket, feed with history — a working text chat.
Stage 3: threads, reactions, pinned, bookmarks, attachments, search, mentions, notifications, presence.
Stage 4: LiveKit, tokens, channel call, call screen with the full control bar, layouts, devices.
Stage 5: personal 1:1 calls, global modals, and the minimized-call widget.
Stage 6: rooms with links, public entry page, guests, and invites with permissions.
Stage 7: recording via Egress, moderation, quality modes, adaptive degradation.
Stage 8: tests, README, acceptance checklist.
After each stage: run the build and tests, fix type and linter errors, and only then move on to the next stage.
=== 11. ACCEPTANCE CRITERIA ===
1) Two users in different browsers exchange messages in a channel in real time and see "typing", reactions, and unread counts.
2) A thread reply does not clutter the channel; the parent message's reply counter grows.
3) A file up to 50 MB uploads, an image gets a thumbnail, and the file appears in the "Files" panel.
4) Search finds a message by a word in an inflected Russian word form.
5) Channel call: a second participant sees the banner and joins; microphone, camera, screen share, layout switching, and device selection all work.
6) Personal call: the other party gets an incoming-call popup; accept, decline, and timeout all work.
7) Room by link: a guest without an account enters a name, sees the preview, joins the call, and writes in the room chat.
8) The host mutes, disables video, and removes a participant; "End for everyone" closes the room for all.
9) Recording starts and stops, the file appears in the list and downloads; everyone sees the recording indicator.
10) A minimized call keeps going when navigating to another section of the app.
11) A user from another organization receives not a single object of a foreign organization — neither via API nor via socket.
12) The interface works on mobile: one column, a bottom bar, touch targets no smaller than 44px.
In the README describe the launch commands, environment variables, the container startup order, and known limitations.
How to run it
The prompt walks the agent through eight stages, and after each one it requires running the build and tests before moving on:
| Stage | What appears |
|---|---|
| 1 | Repository, docker-compose, migrations, auth, seed data |
| 2 | Channels, messages, socket — a working text chat |
| 3 | Threads, reactions, attachments, search, mentions, presence |
| 4 | LiveKit, tokens, in-channel call, call screen |
| 5 | 1:1 direct calls, incoming-call modals, minimized-call widget |
| 6 | Rooms with links, guest access, invites with permissions |
| 7 | Recording via Egress, moderation, quality modes, degradation |
| 8 | Tests, README, acceptance checklist |
On the infrastructure side you'll need Docker and docker-compose — the whole stack runs in containers. To test calls between devices you'll also need a domain with TLS: without HTTPS the browser won't grant access to the camera and microphone — a WebRTC limitation, not the project's. The text chat (stages 1–3) runs fine on localhost.
A handy tactic is to feed the stages in portions: "do stages 1–2 and show me the result", check it by hand, then continue. It's easier to catch a problem in the foundation than to dig it out from under several floors of calls built on top.
What you get
A quick inventory of what the prompt describes:
- Chat: public, private and direct channels; threads with a reply counter; reactions; pinned and saved messages; full-text search with Russian morphology; files up to 50 MB with image previews; presence and "typing…" indicators; mentions and notifications.
- Calls — three scenarios: an in-channel call (members see a banner and join), a 1:1 direct call with an incoming-call flow and a timeout, and rooms with a permanent link where a guest joins without an account — they type a name and land in the room's call and chat.
- Call modes economy / standard / conference: in economy the camera is forbidden at the LiveKit token level, not by a checkbox in the UI. Plus MP4 recording via Egress, host moderation (mute, camera off, kick) and adaptive quality degradation under load.
- Database: 14 chat-and-calls tables plus users and organizations, versioned migrations, indexes for the feed and search. Every query is filtered by organization_id — multi-tenant isolation is baked into the schema, not bolted on afterwards.
- Acceptance: 12 verifiable criteria — from "two browsers chat in real time" to "a user from another organization gets not a single object via API or socket".
Variations
- Chat only, no calls. Drop section 5 ("LiveKit integration") and everything call-related from sections 6–7 (call screen, control bar, stage), and keep stages 1–3 and 8. You get a self-contained team messenger — LiveKit and Egress come out of docker-compose too.
- Call rooms only, no messenger. A "your own Telemost" scenario: the core is sections 5 and 6 (LiveKit, the call and room screens) plus the rooms and guests tables. Channels, threads and search can be cut out.
- Swap stack details. A different UI kit instead of Tailwind, another icon library, your own design — edit sections 1 and 7 freely. What you shouldn't touch is the two-transport architecture: Socket.IO for signaling and chat events, LiveKit (SFU) for media. Trying to "simplify" it into a hand-rolled WebRTC mesh is the classic road to a project that works for two people and falls apart at five.
Pro tips
- TLS isn't a "later", it's a "now". The most common complaint about projects like this is "the camera won't turn on": the browser blocks getUserMedia without HTTPS. Get a domain and a certificate before stage 4, or you'll be debugging calls that by definition cannot start.
- ICE and the docker bridge. The prompt spells out livekit.yaml for a reason: rtc.node_ip set to the public address, use_external_ip, and excluding the docker interfaces. Skip that and the container's address ends up among the ICE candidates — and nobody outside can connect. If a call "hangs on connecting", start here.
- Check the acceptance criteria one at a time. The agent will happily report "all done" — don't take its word for it. Walk through all 12 items by hand, in two browsers, with a real file and a real guest joining by link. Especially the organization-isolation item: that's the bug that costs more in production than all the others combined.
- LiveKit keys never reach the frontend. The API key and secret live only on the backend; the client gets a short-lived token with permissions baked in on the server. If a review turns up LIVEKIT_API_SECRET in frontend code, the agent cut a corner — make it redo the work.