Architecture
How Monky is built on the inside: the components, how they talk to each other, and why the decisions were made that way.
This page describes what exists in the code today. If you are looking for the project's original specification — with MVP, phases and future ideas — it lives in docs/especificacao-tecnica.md.
The core idea: two separate planes
Everything in Monky starts from one split: what the server controls and what travels directly between people.
The server handles login, channels, chat, roles and signaling. In the default mode, it introduces participants to each other and then steps aside: voice, video and screen travel P2P over WebRTC, without passing through it.
That has two consequences which explain nearly everything else in the project:
- The server's bandwidth barely matters. It carries no media, so a modest VPS is enough for the group. The bandwidth cost sits with the participants.
- The conversation is not readable by the server. Even the person hosting cannot listen in — WebRTC is encrypted end to end between the peers.
Both consequences describe the default mode
Whoever hosts can switch the media plane to SFU mode, and then neither statement above holds: the server starts carrying all of the channel's media and gains access to its contents. SRTP terminates at mediasoup, which decrypts each packet and encrypts it again for every recipient — that is how any SFU works, and it is the price of not sending the same video N times. There is still no third party involved, since the server is yours, but "not even the host can listen in" is a property of P2P Mesh, not of Monky.
The components
The repository is a monorepo with npm workspaces:
| Workspace | What it is |
|---|---|
apps/client | The Electron app — the interface, and also the host when you host from the app itself. The same renderer builds the web version (npm run build:web) |
apps/server | The server: WebSocket, SQLite and the Monky CLI |
apps/light | Native headless core under development; not the published desktop client covered by the user guides |
packages/shared | The contract between the two: protocol types, validators, limits and quality profiles |
packages/dm | Friends and direct messages: the same engine on the desktop and in the web version, with crypto and storage supplied by each |
packages/bot-sdk | SDK, bot contracts, voice connection, local execution and bot CLI packaging |
packages/shared is what stops client and server from drifting apart: both import the same types and the same validators.
Light's implementation and current limitations live in its own README. The interface sections below describe the Electron client.
The client
Three processes
Electron splits the app into three contexts, and Monky respects that split:
The renderer runs with contextIsolation: true and nodeIntegration: false: the UI has no access to Node. Anything that needs the operating system — picking a screen to share, reading the native audio module, touching the tray — goes through the window.api bridge exposed by the preload.
The bridge has a single contract, PlatformApi (src/renderer/platform/api.ts). On the desktop the preload implements it; in the web version, src/renderer/platform/web does, with browser features (IndexedDB, WebCrypto, File System Access). Each implementation declares its capabilities. The interface is the same on both: what the platform lacks, such as hosting a server or global shortcuts, appears in the same place, locked and marked "Desktop". Inside a window or dialog, a notice at the top explains it and offers to download the app; elsewhere, activating the item opens that offer. One rule decides it, featureAvailability (platform/capabilities.ts), and the locked controls come from utils/desktopOnly.ts. Only the desktop's title bar and window buttons are left out on the web.
When a feature exists on both, its rules live in one engine and each platform supplies the storage. Direct messages use packages/dm. Miniapp storage on the device uses botDeviceStorageEngine.ts, in packages/shared: the desktop writes files from the main process, and the web writes to IndexedDB on the app's origin, never on the miniapps' origin.
Three barriers protect that bridge. The Content-Security-Policy in index.html only runs the app's own scripts, so HTML injected by mistake does not execute; a new kind of resource (another iframe, worker or origin) has to be added to that policy. No window leaves its own document or opens other windows, except Picture-in-Picture and links handed to the browser. And sensitive channels — identity, backups, hosted servers, local files — only answer the main window; the overlay gets a bridge of its own, with just what it needs to draw the call.
The UI uses no framework
Possibly the project's most unusual decision: the renderer is plain TypeScript and DOM. There is no React, Vue or Svelte. Views build their own HTML with template strings and re-render themselves.
SessionManager keeps connections, chat, members and miniapps isolated per server session. The active session's stores emit on the UI bus (appEvents); background stores update their data through a silent bus. Preferences and the active call have global scope. Switching the viewed session does not close the connections.
Views subscribe to relevant events; the diagram shows this logical flow, not one store mixing every server's data:
The bus events are typed in core/appEventMap.ts: each name declares what it carries, and an emitter or subscriber using another shape does not compile. Server messages arrive as message.<TYPE>, with the payload of the protocol map (ServerToClientPayloads, in @monky/shared). Stores follow three shapes:
- Per session (
serverStore,chatStore,botScreenStoreandParticipantManager): they emit on their ownbus, which isappEventsfor the visible session and the silent bus for the others. The rest of the app reaches them through a proxy that points at the active session. - Global (
settingsStore,connectionStoreandvoiceStore): they emit onappEventsdirectly, and the event says what changed;settings.updated, for instance, carries the keys that changed.voiceStorealso changes during other servers' events, so it notifies throughemitOutsideRouting. - View models (
favoritesStoreandCommunityFeed): they tell the views using them through their ownsubscribe().dmStorehas a bus of its own, typed byDmEvents.
Domain modules, bound once at startup, apply the server's messages to the stores: core/serverStateEvents.ts (members, roles, channels and settings), core/chatEvents.ts and core/voiceEvents.ts. A server's screen (views/MainView.ts) composes the parts in views/main/: header, channel tree, members, user card and call notices.
A view that queries a server by request, such as the audit log and the monitor, is tied to the server, connection and member of the moment it opened by a SessionBinding (core/sessionBinding.ts): once any of them changes, it stops instead of mixing in another server's data. BoundRequests retires each request once it settles and the pending ones when the view closes, and watchVisibleSession (core/visibleSession.ts) revalidates access when the server changes and closes the view once it is no longer the visible one.
The UI kit
The pieces every view repeats live in one place, and a new view uses them instead of writing them again:
- Modal shell (
utils/modalShell.ts):openModal({ card, ... })puts the card on the modal stack and handles the rest. A single capture-phasekeydownondocumentacts only while the modal is on top with no popup of its own open: Escape closes (or first clears adata-clear-on-escapefield) and Tab cycles inside the card. The card is always a named modaldialog; focus starts on the first control below the header and goes back, on close, to whoever opened it. The shell also closes the modal throughdata-modal-closebuttons and a press that starts on the backdrop;canClose(reason)holds it open during an operation. When the visible server changes (closeWithSession) or the owner'ssignalends, the modal leaves at once, with no exit animation and with its popups: another server's content must not linger over the new one. Every listener of the content usesshell.signal. A view that re-renders writes insideshell.card, never replacing it: while a child dialog is open, the stack hides the card below, and a new card would escape that. The order between modals comes from the stack (utils/modalSurface.ts), never from az-indexof each modal.openCommunityModaland the shortcut capture dialog (ShortcutCaptureDialog) are thin layers over the shell. - Feedback (
utils/feedback.ts): an operation error goes to the shared alert toast throughreportError(message, { field, focus, log, error }), which also marks the fieldaria-invaliduntil it is edited again. No error message is ever left loose in the layout. A failed load is something else: the content gives way to a notice with "Try again" (renderLoadingError, inutils/loadingSkeleton.ts).copyTextandopenExternalUrlconfirm or explain in the toast.useAppValidation(form, signal)replaces the browser's native balloon, which follows the system language, with an explanation in the app's language. Two exceptions are deliberate: a terminal media failure that arrives without an action of the person (voice reconnection, microphone, camera, screen capture) stays in a dialog so it is not missed, and an error of the stage in fullscreen stays inside the fullscreen element, since a toast inbodywould not show. - Choice controls (
utils/formControls.ts): on/off is alwaysrenderSwitch({ id, checked, label | labelledBy, ... }), a hidden checkbox withrole="switch"and a name, and keyboard focus shows on the track anywhere. A choice among a few options becomes cards (renderChoiceCardsandselectChoiceCard): buttons witharia-pressed, styled by.input-mode-card, never radio buttons. The P2P/SFU voice mode uses them throughrenderVoiceModeCards(utils/voiceModeInfo.ts). - Settings tabs (
views/settings/settingsLayout.ts): Settings and Server Settings declare their tabs once, in a table (SettingsTabEntry: id, icon, label), and build the sidebar and the title withrenderSettingsTabButtonsandrenderSettingsTabLabel. In Settings, each tab of the table also says how it renders, binds, shows, hides and cleans up. - Community wizards (
views/community/wizardShell.ts): the poll, form and event wizards build the same frame withmountWizard. The stepper and the footer stay still while the step slides between them, forward or back, and each new step starts from its top with its title focused. Back and Next wait for the slide to end. Since the footer stays in place, the second click of a double click lands on the button the first one just changed; that click, which the system counts as part of the same gesture (event.detail2 or more), is ignored. So a double click moves a single step and never publishes what nobody reviewed, with or without reduced motion; the keyboard and a new click work as usual. Each wizard says when a step may be left: with a disabled button (ready) or with an explanation in the toast (leave). The draft closes at once when the visible server changes. - Audience picker (
views/ResourceAudiencePicker.ts): who can see a community resource or watch a private screen share. A searchable list that also opens from the keyboard (arrows on the closed field), honors theAUDIENCE_LIMITSfrom@monky/shared, and takes its own texts and the on/off switch of whoever uses it (externalSwitchwithsetPrivate). - Stage tile volume (
views/StageVolumeControl.ts): the mute button with the vertical 0–200% slider above it, the same for a shared screen and a miniapp. It opens on hover and from the keyboard (Tab goes from the button to the slider); Escape closes it, and muted audio keeps it closed. - Motion: entering and leaving through
utils/surfaceMotion.ts, the modal stack throughutils/modalSurface.ts, expansions throughsetSurfaceVisible(utils/surfaceVisibility.ts; a panel that unfolds through its own CSS transition, like the soundboard's, callsrevealWhenUnfoldedwhen opened, so it ends in view the same way), steps that swap the whole card throughreplaceModalStepand steps inside the fixed area of a wizard throughslideStepContent(both inutils/modalSteps.ts), with a forward and a back direction. A tab swap is aview: opacity only. Visible scrolling goes throughutils/scroll.ts. All of them honorprefers-reduced-motion. - Resizable panels (
utils/resizableEdge.ts): the edge a person drags to widen a panel becomes a window splitter, with a role and bounds for screen readers, arrows, Home and End, and the width saved and restored. The channel list and the voice chat use it.
The services
Every large client responsibility lives in its own class, under src/renderer/core/:
| Service | Responsibility |
|---|---|
NetworkClient | WebSocket, authentication, heartbeat and reconnection |
WebRtcManager | Shared media orchestration, local publications and P2P connections; delegates SFU transport |
webrtc/SfuClientEngine | SFU transport: negotiated capabilities, producers, consumers and reconnection |
AudioProcessor | Microphone, noise suppression, speech detection |
VideoService | Shared camera capture, local effects and the registry of published screens |
ParticipantManager | Who is online, in which channel and in what state |
SoundboardService | Soundboard clips and shortcuts |
KeybindService | Global shortcuts |
AttachmentUploader | Chat attachment uploads |
UpdateService | Update check and banner |
Screen sharing: shared behavior, separate transport
Sharing a screen is the same feature in both modes. A transport should not carry a second implementation of capture, quality preferences or codec rules.
| Responsibility | Owner |
|---|---|
| Local registry of published screens and their previews | VideoService; capture, encoding and audio belong to the native source |
| Stop/replace controls, associated audio and updating the correct session | screenShareControls.ts, used by the picker, views and call departure/switching |
| Starting, cancelling and retiring each source | WebRtcManager's startNativeScreenShare() and removeNativeScreenSource(), carried out by webrtc/NativeScreenController.ts (local sources in webrtc/nativeScreen/LocalScreenSources.ts) |
| Viewing intent, independent of the view and mute preference | VoiceStore, owned by the call session and applied to both transports through WebRtcManager |
| Screen codec and encoding mode | Quality preferences, probed by core/screenEncoding.ts and verified when the native source is admitted |
| Microphone and camera bitrate/FPS limits and adaptation preference | webrtc/mediaEncodingPolicy.ts, used by P2P and SFU |
| Camera codec preference order | webrtc/codecPreferences.ts, with capabilities supplied by each transport |
| ICE route in use (ping, relay indicator and diagnostics) | webrtc/rtcStats.ts, used by P2P and SFU; the call measures once and the stage and the sidebar only display it |
| Serialized RTP parameter changes | webrtc/rtpSenderParameters.ts: overlapping updates do not overwrite each other's transaction |
| Receiving and associating remote media with the UI | webrtc/RemoteMediaRouter, shared by both modes |
| Native capture/encoding/transport and Main-process ownership | nativeScreenSharing.ts and native/screen-share |
| Native-screen subscriptions/presentation or compatible Chromium reception | webrtc/nativeScreen/RemoteScreenPresentations.ts (automatic reconnection in RewatchPolicy.ts) and webrtc/BrowserScreenSubscription.ts |
The renderer does not publish screens: the voice connection carries only the microphone and camera. Each screen is captured, encoded and sent by its native source over its own connections, in both P2P and SFU. Chromium only takes part as an optional receiver of those screens, when selected in settings (BrowserScreenSubscription, over P2P or SFU).
In the web version, the same command and event contract as the native source is answered by src/renderer/platform/web/webScreen: the capture comes from the browser's own dialog (getDisplayMedia), each requested profile is a copy of the capture scaled down by the browser, and it is sent as H.264 over a connection of its own per viewer in P2P or through a pair of screen producers on the SFU, with the same monky-native-screen-p2p protocol and rendition data. The default format is H.264 Main, a better picture that only Chrome, Edge and the desktop watch; the publisher can choose Constrained Baseline (42e0.., packetization-mode 1), which every receiver decodes, Firefox and Safari included. The codec is pinned by setCodecPreferences in P2P and by the producer's codec on the SFU, even when the browser would use its hardware encoder. The renderer above it does not change; the picker is the browser's dialog (views/screenShare/BrowserScreenSharePicker.ts), and the web version always watches with the browser's receiver. A browser without the share's profile or codec (Firefox facing H.264 Main or AV1) gets a message naming the format, not the generic connection failure.
The desktop app offers the same pair of formats in Quality & sharing, with Better quality as its default. The format is an H.264 profile and does not narrow the codec: Automatic still picks AV1 when available. When the screen is sent in H.264 with Compatible, Main passes --h264-profile=constrained-baseline to the capture host: x264, NVENC and AMF use OBS's baseline profile, and VideoToolbox uses H264_ConstrainedBaseline_AutoLevel. The host and the engine set constraint_set1 on a Baseline SPS without slice groups or redundant pictures, which is Constrained Baseline (H.264 A.2.1). The engine (videoInput: 'encoded-h264-cb') negotiates only that profile and serves receivers at any advertised level, since browsers advertise the default Level 3.1 and decode up to 5.2; so a compatible source never exceeds Level 5.2.
The picker only uses native capture and explains why when it is unavailable: there is no fallback to Chromium capture. Closing the picker cancels pending acquisition without ending already published screens. Screen audio belongs to the native source that captures it, so stopping the screen must not produce a late audio-sharing announcement. The controls helper snapshots the call session instead of sending updates to whichever server happens to be visible when an asynchronous operation finishes.
Profile values are per-send ceilings, not FPS guarantees or a total upload budget. P2P may send the same screen to several destinations; SFU normally sends it to the server once. These ceilings do not promise sustained 1080p120.
The native path uses libobs/WGC → AMF H.264 → native WebRTC on qualified Windows x64 systems, without decoding and re-encoding video at the publisher. Stretch scaling happens before the encoder. On Windows receivers, Media Foundation decodes and SharedTexture supplies frames to the stage and overlay. Chromium receivers negotiate the actual H.264 profile before accepting a subscription.
On macOS 14+ (arm64/x64), ScreenCaptureKit captures windows/monitors, Core Image/Metal scales; VideoToolbox encodes H.264 and libaom encodes software AV1. The AV1 encoder reads NV12 in C++ outside the Renderer, retains the Mach timestamp and applies bitrate/keyframe changes on the same serial capture queue. Process-isolated RTC transports video over P2P/SFU; VideoToolbox decodes H.264 and the software AV1 decoder handles reception of that codec. Mach rights transfer IOSurfaces to Main; release requires Chromium's actual reference-retirement proof. Original audio uses Mach timestamps, not QPC. Per-decoder counters and native clocks measure FPS without confusing the requested profile with observed delivery.
Each demanded profile owns a pipeline; viewers of the same profile share capture/encoding, but not viewing authorization. Limits are four profiles per source and 16 viewers per profile. With no demand, the pipeline closes and only the source descriptor remains. A new subscription creates new owners instead of reusing retired engines. Centralized IPC, Main-process validation and private closure receipts preserve resource ownership.
What is stored on your machine
The client uses localStorage for simple preferences, IndexedDB for camera effects and the background image, and native storage for identity. These stores have different purposes and guarantees.
| Key | Contents |
|---|---|
monky_settings | Preferences: quality, devices, volumes, shortcuts, soundboard |
monky_nickname / monky_avatar | Your visual identity |
monky_saved_servers | Servers you saved to reconnect to, with the identity key pinned for each |
monky_created_servers | Servers you created on this machine |
monky_favorites | Local sound favorites by full path and server favorites by address/port |
monky_device_id | Identifies this device (lets the same person use two machines) |
monky_language | Interface language |
In the monky-camera-effects IndexedDB database, preferences and one normalized image share a transactional record. Persistence failure does not change the in-memory preference; corrupt data is not interpreted as consent to transmit the camera without an effect.
Automatic separation uses Robust Video Matting (RVM/MobileNetv3) and TensorFlow.js with bundled graph and weights, without a CDN or frames sent to an API. Inference, compositing and chroma key run in an OffscreenCanvas worker, with at most one frame in flight. MediaStreamTrackProcessor reads capture frames directly; the video-element fallback logs a warning when that API is unavailable. Chroma, compositing and separable blur use WebGL2 shaders without reading or looping over full-resolution video pixels in JavaScript. Blur excludes the person before filtering and normalizes by the available background weight without enlarging the image. This prevents foreground colors from bleeding into the contour or producing a second, displaced silhouette. RVM takes full-resolution RGB and maintains four recurrent states. Its internal ratio is min(1, 480 / max(width, height)); foreground RGB and alpha retain full resolution without CPU readback. Composition shares TensorFlow's WebGL2 context and restores its state. Native alpha is not thresholded or softened using legacy segmentation settings; those values remain stored but the UI explains they are not applied. AI effects fail explicitly without WebGL2. Physical chroma retains the Canvas2D fallback with a log warning; the AI model is never silently replaced. GPU context loss during processing stops the camera rather than publishing raw frames or silently switching backends. The model is loaded lazily and reused across blur, color and image modes. Reconfiguration or resolution changes reset temporal memory; during chroma the model stays idle without inference, retaining compiled shaders. Configuration and the first frame each have up to 60 seconds for initialization and cold shader compilation, which can exceed 30 seconds on Metal. The first frame at a new resolution also receives that compilation budget. Subsequent frames retain an 8-second watchdog. No frames are published during preparation, and cancelling capture does not wait for these deadlines. CI tests use D3D11 WARP on Windows and Metal on macOS ARM, retaining real inference without changing the application's graphics selection. Turning effects off or ending capture releases the reader, tensors, textures, context and worker. Resolution and frame cadence follow the selected profile. The optional limiter, off by default, caps both at 1280 × 720 and 30 FPS without upscaling a smaller capture or duplicating frames to compensate for capture or processing limitations. CameraPublication coordinates replacements and failures with P2P/SFU publishers; capture belongs to VideoService, not to the preview or producer.
The private key is deliberately absent from that list. It is what proves who you are (see Authentication) and it never reaches the renderer: it lives in identity.json, inside the userData folder, encrypted with Electron's safeStorage — the operating system's own vault. Where the system offers no encryption the file is written in the clear, and the record itself says which of the two happened.
The server
The three ways to run it
The same server code goes up in three shapes, and the difference is not technical — it is about who looks after it:
Hosting from the app is the two-click path, and that is why the client imports @monky/server directly: no separate process, no admin port. The price is that the server lives only as long as the app does. The app can host several servers at once in the same process, each with its own port and data folder; each instance keeps its own logs.
The CLI exists for the opposite case — a server that should not depend on somebody keeping a window open. It manages several servers on the same machine, each with its own data folder and its own PM2 process.
Layers
There is no dependency injection container: wiring is explicit, done by hand in MonkyServer.create(). You can read the file and see exactly what depends on what.
Beyond the WebSocket, the server exposes a few HTTP routes: /health, /preview, /invite-info (addresses for the invite screen, answered only on the server's own machine; members get the same over the WebSocket), /avatars/* and the /attachments upload/download.
The default port is 3000.
The protocol
Every message has the same shape:
{
type: MessageType, // 'CHAT_SEND', 'VOICE_JOIN', 'RTC_SIGNAL'…
requestId?: string, // echoed in the response, for correlation
payload: T
}The requestId is what lets the client know which response belongs to which request — the WebSocket is asynchronous and responses do not necessarily arrive in the order they were asked for.
Payload validation uses zod, with the schemas in packages/shared — the very same ones the client uses to validate before sending.
Negotiated compatibility, never an unrestricted downgrade
Since protocol 26, client and bot minimum versions are independent. Today they are 35 for clients (MIN_CLIENT_PROTOCOL) and 24 for bots (MIN_BOT_PROTOCOL), plus feature flags in AUTH_CONNECT/AUTH_SUCCESS. The native 4K/80 Mbps screen contract requires updating client and server; older clients are not silently downgraded. Version 24 bots retain their existing audio and command contract. Message blocks and message-limit settings are enabled only when negotiated. The SDK may retry authentication once using an old server's known contract, respecting the bot floor. Versions below the floor are rejected; future versions must explicitly advertise a compatible range.
Additive features need not raise the floor. Critical fixes or incompatible changes must raise MIN_CLIENT_PROTOCOL and/or MIN_BOT_PROTOCOL in protocolCompatibility.ts, update release metadata and test rejection before publication. The CI major-version policy for PROTOCOL_VERSION changes remains in effect.
Protocol 37 adds BOT_DIAGNOSTIC, the only bot message accepted before authentication. The runtime CLI's doctor sends it over its own connection: the server checks the token without binding the key (TOFU) or replacing the running bot's session, reports protocol compatibility and tests the bot's public ports from outside. A port counts as reachable only when it signs a fresh challenge with the bot's Ed25519 key; private addresses (other than the requester's own), system ports, redirects and timing differences reveal nothing about third-party hosts. Older servers answer it as a malformed message. See Ports, settings and checks.
Identity authentication
Login is challenge–response with public-key cryptography. You have no account and no sign-up: your identity is your key pair.
The clientId is derived from the public key itself, so it cannot be forged: without the private key you cannot sign the challenge.
The server password, when present, protects entry and is checked by the server before the challenge. It differs from your local identity-backup password. The private key and that backup password are not sent during login.
The signature is valid for one server only
If the signature covered only the nonce, a server M you joined could open a connection to another server A with your public key, relay A's challenge to you and hand your answer to A, signing in there as you. That is why each server has its own Ed25519 identity key and the signature names that key (negotiated feature server-key-auth):
- In
AUTH_CHALLENGE, the server sends the nonce, its public key and a signature ofmonky-server-auth:v1:<server key>:<nonce>, proving it holds the key it presents. - The app checks that proof and signs
monky-auth:v2:<server key>:<nonce>, never the bare nonce. An answer obtained by M is bound to M's key and is worthless at A. - On the first protected sign-in, the app pins the key for that saved address (TOFU) and keeps it in the saved server itself, which travels with the servers backup. An invitation may also carry the key; it still opens in earlier versions of the app, which only do not pin it.
The app decides (decideServerKey, in @monky/shared) before signing anything:
| Situation at the address | What the app does |
|---|---|
| Never presented a key | Signs the nonce, as before (older servers keep working) |
| First key, not pinned by any other saved server | Signs bound to it and pins it on joining |
| The same pinned key | Signs bound to it |
| Another key | Refuses (Key changed). Only a new invitation carrying the new key replaces it |
| No key, after presenting one | Refuses: there is no downgrade to the old format |
| The key of another saved server | Refuses, even from an invitation. A second address of the same server is added from the saved server's menu, under Add address, which copies the pinned key |
The server does not accept a downgrade either: after a member's first bound sign-in, it refuses that identity's bare-nonce signatures (FEATURE_REQUIRES_UPDATE), since a relaying server could still obtain them from an app that never saw it with a key. Bots do not use this challenge (see bot-key-proof).
The server's private key lives in server.db (table server_identity), created on the first start that does not find one and written to disk before the server accepts connections. It therefore follows the database copy made before each update, the data folder moved to another machine and every restart. Changing the key (deleting the table, or starting a new database at the same address) makes apps refuse the server until they receive a new invitation.
Monky Light follows the same rule and keeps the pinned keys in its profile, in monky-light-servers.json, by server address. It accepts an invitation link where it accepts the address, which is how it receives a new key; it has no Add address.
Residual risk. The protection holds from the first protected sign-in to A. If you never joined A with an app that has the feature, a server M visited for the first time can present A's key and relay its challenge; the app cannot tell that apart from a legitimate new server. Once you join A, its key is pinned and any other address presenting it is refused.
Sessions: the same person on several devices
A session is identified by userId:deviceId, not just by the user. That is what lets you be on the desktop and the laptop at once while appearing as a single person.
- Reconnecting from the same device replaces the old connection (avoiding ghosts after a network drop).
- Connecting from another device creates a new session, up to a cap of 3 simultaneous sessions per identity.
The cap exists so a single identity cannot exhaust the server's resources (connections, audio, bandwidth) by opening endless devices. It is independent from maxUsers, which counts registrations — several sessions of the same person still take a single seat.
Database
SQLite, in a server.db file inside the server's data directory. Migrations are numbered .sql files, applied at startup and recorded in a schema_migrations table — the server only runs what it has not run yet.
| Table | Holds |
|---|---|
server_meta | Server configuration: name, password, owner, limits, icon |
server_identity | The server's Ed25519 identity key (signature binding) |
users | Members, each with their public key |
channels | Voice and text channels |
messages | Chat history |
mentions | Mentions, for highlighting and notifying |
message_attachments | Message attachments |
roles / user_roles | Roles and who holds each one |
schema_migrations | Bookkeeping of applied migrations |
Roles and permissions
Permissions are bits combined into a mask. Two roles are special:
- Admin — gets every permission and cannot be deleted.
- Member — the default role for anyone who joins.
The check is centralised in PermissionService.checkPermission(). The server owner is a case apart: they get admin permissions regardless of which roles they hold.
Abuse protection
| Protection | How |
|---|---|
| Message flood | Sliding window: 10 messages every 5 s |
| Message size | 16,000 characters by default; configurable per server, 0 disables the character limit; WebSocket packets remain limited to 8 MiB |
| Avatar | 5 MB, and the file must carry a PNG, JPEG or WebP signature |
| Attachments | Per-file limit and a total server budget, both configurable |
| Soundboard | Audio refused above ~4 MB |
| Path traversal | File names go through basename and the final path is checked against the allowed folder |
Note the detail on avatars: validation looks at the file's magic bytes, not its extension. Renaming an executable to .png does not fool the check.
The media plane
Monky supports two voice and media topologies: default P2P Mesh and SFU (Selective Forwarding Unit) powered by mediasoup.
Topology 1: Full Mesh (Default)
Each participant opens a direct connection to every other one.
With N participants, each person keeps N−1 connections and the channel has N(N−1)/2 in total. Whoever shares a screen sends the same video N−1 times, once per peer.
When to use P2P Mesh
Excellent for small friend groups and lightweight/free VPS instances with low CPU or bandwidth, since the server does not relay audio or video packets.
Topology 2: SFU (Selective Forwarding Unit)
For the browser engine's SFU call, the client opens a pair of WebRTC Transports with the host server:
sendTransport: Transmits local audio and video tracks (microphone and camera).recvTransport: Receives forwarded tracks from all other participants routed through themediasoupworker.
The server identifies this pair with purpose call, also used when purpose is omitted. Screen engines with their own connections can use a screen pair, restricted to screen video and audio. Producers and consumers record their transportId: rebuilding one purpose does not discard the other's resources. Pending creations have the same scope: late worker results are closed after cancellation, departure or channel switching, without registering abandoned transports or affecting a replacement connection. Voice connection health uses the call pair; screen health is queried separately. This prepares native-engine isolation without changing the pair currently used by the renderer.
Explicit SFU_CLOSE_WEBRTC_TRANSPORT selects a screen transport ID, not the entire pair, and responds with SFU_WEBRTC_TRANSPORT_CLOSED. SFU_PRODUCER_SET_PAUSED is also restricted to screen media owned by the session. Producer and consumer pause/resume waits for the worker response and checks that the resource still exists. Requests with a requestId receive a correlated acknowledgement or error, including producer/consumer closure; existing controls without a requestId still receive no extra acknowledgement.
The broadcaster sends each stream only once to the server, rather than one copy per recipient. Fewer outgoing copies alone do not guarantee resolution, FPS or a specific CPU/GPU cost.
Resilience & Automatic Reconnection
If the SFU process encounters errors or unexpected downtime, clients show a notice and rebuild the SFU session automatically, backing off between attempts until the server is back.
There is no drop to P2P: a mesh where only the side that noticed the failure switches protocol never forms, because the other side keeps answering as an SFU client and discards the incoming offer. That would leave a silent call behind a reassuring notice — so the client reconnects instead of degrading.
When an administrator explicitly switches from SFU to P2P, participants are notified and the client tears down its previous transport before automatically returning to its own channel. Re-entry uses a temporary, one-use authorization tied to that session and channel, respecting current permissions and limits. Leaving voluntarily, being removed, or starting another call cancels the automatic return.
Signaling
The server only delivers envelopes. It rewrites the sender (so nobody can forge an identity) and refuses delivery if the two peers are not in the same voice channel.
Getting through NAT
Almost nobody has a direct public IP, so peers need to work out how to reach each other. Monky uses public STUN servers (Google and Cloudflare) so each side can discover its own external address.
TURN is optional, and off by default
STUN only discovers the path; it does not relay anything. When the network is too restrictive — symmetric NAT, corporate firewall, some carrier CGNATs — there is no direct path and the media connection fails.
A TURN server solves it by relaying the media. But TURN carries video, and costs bandwidth proportional to usage — which reintroduces exactly the cost the P2P architecture avoids. That is why Monky's relay is optional and ships off: whoever hosts decides whether to pay that bandwidth.
When enabled, the server runs a coturn alongside it and hands out the credentials at login. ICE still prefers the direct route and only uses the relay for the pairs that genuinely cannot connect. Details in Media relay (TURN).
When a connection drops or stalls, WebRtcManager first tries an ICE restart (renegotiating the path without tearing down the call) and, failing that, rebuilds the connection to that peer from scratch.
Audio
Capture already requests built-in WebRTC echo cancellation and automatic gain. The diagram shows the default RNNoise engine; Speex and GTCRN occupy the same point in the graph. All three run in AudioWorklet, with WASM bundled by the existing @sapphi-red/web-noise-suppressor dependency. Selecting one disables built-in suppression to avoid stacking processors. WebRTC (built-in) suppression and no suppression are also available.
AudioProcessor prepares the next engine before replacing the internal connection, preserving the destination track sent through P2P or SFU. Local preview shares the processed call stream when possible; when it needs its own capture, it applies the same engine without connecting to speakers or WebRTC.
Outputs resolve general and per-category preferences. Voice and screen audio have separate AudioContexts across the entire 0–200% volume range. Chromium shares a native renderer between WebRTC tracks: audio elements stay active at zero volume for decoding, and only the category graphs produce sound. The shared native output follows voice to align the echo-cancellation reference; it is never redirected to the chosen screen output. Native chat players, including the expanded viewer, apply the media output before starting playback.
Mute and deafen disable the track (enabled = false) instead of removing it. That way there is no need to renegotiate the connection on every click of the mute button.
Video and screen sharing
The camera follows the resolution and FPS of the selected quality profile.
Screen sharing is separate from the camera — you can transmit both at once. The source is announced in the call (nativeScreenShares) without requiring reception of its media, and each subscription uses its own connections, outside the voice connection.
Each person can share up to 8 screens at once. The limit is per person, not per call: each screen costs the sharer another capture, another encoder and more upload, while viewers only receive the screens they chose to watch. That is why, from the third screen on, Monky asks for confirmation once per call. Each is identified by its source id (shareId), which ties together the announcement, the subscriptions and the tile on screen.
The screen-shares-8 feature is negotiated. Servers without it accept 2 screens per person, so the client does not offer a third one on that server. Clients without it only receive each person's first 2 screens, which is what their native roster accepts; the server also recomputes whether that person shares audio for such a client.
Discovery is not subscription. Viewing intent belongs to the call rather than a view's lifetime. Watch broadcast authorizes delivery of that source to the recipient; Stop watching revokes it. The choice follows the same source through view and transport reconstruction, but a new broadcast does not inherit it merely because it has the same owner.
NATIVE_SCREEN_SIGNAL carries intent to the publisher: watch and stop come from the viewer; accepted, capture-mode and closed, from the publisher. Each subscription has its own subscriptionId and names the source instance (sourceInstanceId); stopping affects only that subscription. The server authenticates the sender and checks the channel and published source.
In SFU, the catalog remains available without automatically creating screen consumers. Screen/audio consumers start paused on the server and are resumed only after setup finishes and is still valid. Stopping closes the remote and local consumer; pausing a client track alone would not stop bandwidth usage. Late responses must not reactivate a cancelled subscription.
Screen audio is shared per publisher/recipient: it continues while that recipient watches at least one screen from that publisher. Microphone, camera, other viewers and mute preferences are independent. The last subscription to a profile closes its capture, encoder and sending pipeline, including publisher-to-SFU upload. RTCP and signaling remain allowed; this does not promise zero bytes in the call. Client and server negotiate these contracts when they connect, as described in "Negotiated compatibility" above: the viewer list, for example, only appears when both sides advertised screen-viewers.
Screen audio: the native module
The browser does not hand over system sound together with the screen image. That is why Monky has a native C++ module:
| Platform | How |
|---|---|
| Windows | WASAPI process loopback — captures system sound or a specific app's, excluding Monky itself so it does not echo |
| macOS | ScreenCaptureKit (macOS 13+ for audio; native video requires 14+), capturing system audio or the entire selected application after confirmation |
| Others | Not supported — the app keeps working, just without screen audio |
| Web version | The sound the browser hands over with the capture: in Chrome and Edge, a tab's and, on some systems, the entire screen's |
On Windows the same module also identifies the picker's windows and monitors, reads the keyboard layout for shortcuts and constrains the overlay's resizing. That is why it embeds the Visual C++ runtime instead of depending on the redistributable installed on the system, and packaging rejects a build that needs it again. If the module still fails to load, the main process logs the cause in the app log, and the functions that depend on it fail with that cause instead of returning empty lists: on Windows, the picker cannot list windows or monitors. On macOS, sharing keeps working without sound and the app says so.
Each share with sound opens its own capture session. The module accepts up to 8 sessions at once, one for each screen a person may share. Each session runs on its own thread, which notifies the Node loop when it ends; none of them occupies the libuv thread pool, which serves files, DNS and cryptography. The main process decides which sounds may coexist and repeats the picker's checks: a monitor's sound (and, on macOS, any source's, because ScreenCaptureKit delivers the system mix) does not coexist with another, and two windows of the same process do not capture the same sound. Because process loopback includes the processes an application started, the same applies to processes where one descends from the other, like a launcher and the game it opened; the picker receives each window's parent-process chain and the main process checks again, on a fresh snapshot, before capturing. When a screen takes over the applications' sound, each of them switches to a new instance of the same broadcast without audio — viewers follow the switch as they do for a quality change — and the screen's capture starts only after they give up their sound.
Quality and bandwidth
Profiles control resolution, FPS and the bitrate ceiling. The microphone and camera apply these ceilings with RTCRtpSender.setParameters(); for screens they configure the actual native capture/encoder/transport pipeline:
| Profile | Audio | Camera | Screen |
|---|---|---|---|
| Light | 24 kbps | 640×360 @ 30fps · 300 kbps | 640×360 @ 30fps · 700 kbps |
| Standard | 32 kbps | 854×480 @ 30fps · 500 kbps | 1280×720 @ 30fps · 2500 kbps |
| Sharp | 48 kbps | 1280×720 @ 30fps · 1000 kbps | 1920×1080 @ 30fps · 4500 kbps |
| Smooth | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 1920×1080 @ 60fps · 8000 kbps |
| Ultra | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 2560×1440 @ 60fps · 12000 kbps |
| Cinema | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 3840×2160 @ 60fps · 25000 kbps |
| Extreme | 64 kbps | 1920×1080 @ 30fps · 2000 kbps | 3840×2160 @ 120fps · 40000 kbps |
Each step improves the screen without lowering camera or audio, and none goes below 30fps. Ceilings sit above usual streaming values because capture encoders emit a keyframe every second. 4K at 120fps needs H.264 level 6.0 or AV1; when a profile is chosen, Monky checks the encoder and lowers the FPS if it refuses.
In named profiles from 60fps up (Smooth to Extreme), the camera's degradation preference becomes maintain-framerate: under tight bandwidth Monky sacrifices resolution to hold the frame rate; in the other profiles it is the opposite. Native screens request the chosen real profile and do not hide degradation behind an FPS setting.
The Custom profile accepts up to 3840×2160 and 240 FPS (120 FPS at 4K). Viewers can request Source, 1080p60, 720p60 or 480p30 (852×480); different profiles allocate separate pipelines rather than merely resizing the player.
Remember these numbers are per peer. Sharing a screen in Sharp to 4 people asks for roughly 18 Mbps of uplink.
Telemetry
During transmission, telemetry distinguishes configuration, sending, decoding and presentation. RTP statistics use deltas from each report's own timestamp; Media Foundation counters use the worker's observation clock, not the time JavaScript reads a cached snapshot. Unavailable metrics stay unavailable rather than becoming zero. External H.264 transport does not pretend to measure AMF encoding time as if it were an internal WebRTC encoder.
Reconnection
When the WebSocket drops, the client tries to come back on its own with growing waits (1s, 2s, 3s, 5s — the last one repeats). The server keeps the session alive for 20 seconds before announcing the departure, so a quick Wi-Fi hiccup does not throw anyone off the list.
Notice the least intuitive step: on the way back the client tears down every P2P connection and starts over, including the ones that looked alive. It sounds drastic, but it is the most reliable path — while the WebSocket was away other people may have joined, left or switched channels, and there is no way to know which of the old peers still hold. Rebuilding from the new state is cheaper than finding out, peer by peer, who is left.
If the 20 seconds run out before the client is back, the session is closed and the departure is announced normally — the reconnection becomes a fresh join.
Where everything lives
apps/
client/
native/screen-audio/ C++ screen-audio module (Windows/macOS)
native/screen-share/ libobs capture, native RTC, contracts, build and licenses
src/main/ main process: window, tray, updater, IPC
src/preload/ the window.api bridge
src/renderer/
core/ services: network, WebRTC, audio, video
platform/ bridge contract (PlatformApi) and the web implementation
stores/ state + events
views/ the screens
i18n/ PT/EN translations
server/
src/application/ business rules (services)
src/infrastructure/ WebSocket, database, security, logs
src/cli/ the Monky CLI
packages/
shared/ protocol, validators, limits, profiles
dm/ friends and direct messages (desktop and web)Known limits
Things that follow directly from the architecture, and are not bugs:
- Mesh does not scale. Great for a handful of friends, bad for dozens. Larger groups switch to SFU mode, paying with host bandwidth and CPU for what each participant's connection saves.
- TURN is off by default. Very restrictive networks can prevent the media connection even when the server is reachable. An optional relay exists, but it costs the host bandwidth and only runs on Linux.
- Compatibility has a floor. Client and server negotiate version and features when they connect, but only from a floor up (
MIN_CLIENT_PROTOCOLfor clients,MIN_BOT_PROTOCOLfor bots). Anyone below it cannot join until they update, and a new feature only appears when both sides have it. - Web version only on secure servers. An https page only opens
wss://connections (or ones to the same computer). Servers reached by IP, VPN or local network stay in the desktop app. - Screen audio only on Windows and macOS, because it depends on each system's native API. In the web version, the browser decides: Chrome and Edge share a tab's sound and, on some systems, the entire screen's.
- First contact trusts the server. A server's key is pinned on the first protected sign-in. Before that, an unknown server can present the key of another one you have not yet visited with the current app (see residual risk).
- Qualification depends on hardware. The macOS path was exercised on M1 with P2P/SFU audio/video and approximately 57 FPS at 4K60. The probe accepts Main 6.0/4K120, but that does not qualify sustained 120 FPS or Intel GPUs. Loopback and x64 compilation through Rosetta do not replace external-network or Intel-hardware QA.
Learn more
- Monky CLI — command-line administration
- Host on a VPS — putting a server online
- Features — what the app does, from a user's point of view
- CONTRIBUTING.en.md — how to contribute code
