Skip to content

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 core idea: two separate planesThe core idea: two separate planes

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:

WorkspaceWhat it is
apps/clientThe 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/serverThe server: WebSocket, SQLite and the Monky CLI
apps/lightNative headless core under development; not the published desktop client covered by the user guides
packages/sharedThe contract between the two: protocol types, validators, limits and quality profiles
packages/dmFriends and direct messages: the same engine on the desktop and in the web version, with crypto and storage supplied by each
packages/bot-sdkSDK, 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:

Three processesThree processes

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 UI uses no frameworkThe UI uses no framework

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, botScreenStore and ParticipantManager): they emit on their own bus, which is appEvents for 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, connectionStore and voiceStore): they emit on appEvents directly, and the event says what changed; settings.updated, for instance, carries the keys that changed. voiceStore also changes during other servers' events, so it notifies through emitOutsideRouting.
  • View models (favoritesStore and CommunityFeed): they tell the views using them through their own subscribe(). dmStore has a bus of its own, typed by DmEvents.

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-phase keydown on document acts only while the modal is on top with no popup of its own open: Escape closes (or first clears a data-clear-on-escape field) and Tab cycles inside the card. The card is always a named modal dialog; focus starts on the first control below the header and goes back, on close, to whoever opened it. The shell also closes the modal through data-modal-close buttons 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's signal ends, 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 uses shell.signal. A view that re-renders writes inside shell.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 a z-index of each modal. openCommunityModal and the shortcut capture dialog (ShortcutCaptureDialog) are thin layers over the shell.
  • Feedback (utils/feedback.ts): an operation error goes to the shared alert toast through reportError(message, { field, focus, log, error }), which also marks the field aria-invalid until 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, in utils/loadingSkeleton.ts). copyText and openExternalUrl confirm 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 in body would not show.
  • Choice controls (utils/formControls.ts): on/off is always renderSwitch({ id, checked, label | labelledBy, ... }), a hidden checkbox with role="switch" and a name, and keyboard focus shows on the track anywhere. A choice among a few options becomes cards (renderChoiceCards and selectChoiceCard): buttons with aria-pressed, styled by .input-mode-card, never radio buttons. The P2P/SFU voice mode uses them through renderVoiceModeCards (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 with renderSettingsTabButtons and renderSettingsTabLabel. 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 with mountWizard. 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.detail 2 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 the AUDIENCE_LIMITS from @monky/shared, and takes its own texts and the on/off switch of whoever uses it (externalSwitch with setPrivate).
  • 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 through utils/modalSurface.ts, expansions through setSurfaceVisible (utils/surfaceVisibility.ts; a panel that unfolds through its own CSS transition, like the soundboard's, calls revealWhenUnfolded when opened, so it ends in view the same way), steps that swap the whole card through replaceModalStep and steps inside the fixed area of a wizard through slideStepContent (both in utils/modalSteps.ts), with a forward and a back direction. A tab swap is a view: opacity only. Visible scrolling goes through utils/scroll.ts. All of them honor prefers-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/:

ServiceResponsibility
NetworkClientWebSocket, authentication, heartbeat and reconnection
WebRtcManagerShared media orchestration, local publications and P2P connections; delegates SFU transport
webrtc/SfuClientEngineSFU transport: negotiated capabilities, producers, consumers and reconnection
AudioProcessorMicrophone, noise suppression, speech detection
VideoServiceShared camera capture, local effects and the registry of published screens
ParticipantManagerWho is online, in which channel and in what state
SoundboardServiceSoundboard clips and shortcuts
KeybindServiceGlobal shortcuts
AttachmentUploaderChat attachment uploads
UpdateServiceUpdate 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.

ResponsibilityOwner
Local registry of published screens and their previewsVideoService; capture, encoding and audio belong to the native source
Stop/replace controls, associated audio and updating the correct sessionscreenShareControls.ts, used by the picker, views and call departure/switching
Starting, cancelling and retiring each sourceWebRtcManager'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 preferenceVoiceStore, owned by the call session and applied to both transports through WebRtcManager
Screen codec and encoding modeQuality preferences, probed by core/screenEncoding.ts and verified when the native source is admitted
Microphone and camera bitrate/FPS limits and adaptation preferencewebrtc/mediaEncodingPolicy.ts, used by P2P and SFU
Camera codec preference orderwebrtc/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 changeswebrtc/rtpSenderParameters.ts: overlapping updates do not overwrite each other's transaction
Receiving and associating remote media with the UIwebrtc/RemoteMediaRouter, shared by both modes
Native capture/encoding/transport and Main-process ownershipnativeScreenSharing.ts and native/screen-share
Native-screen subscriptions/presentation or compatible Chromium receptionwebrtc/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.

What is stored on your machineWhat is stored on your machine

KeyContents
monky_settingsPreferences: quality, devices, volumes, shortcuts, soundboard
monky_nickname / monky_avatarYour visual identity
monky_saved_serversServers you saved to reconnect to, with the identity key pinned for each
monky_created_serversServers you created on this machine
monky_favoritesLocal sound favorites by full path and server favorites by address/port
monky_device_idIdentifies this device (lets the same person use two machines)
monky_languageInterface 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:

The three ways to run itThe three ways to run 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.

LayersLayers

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:

ts
{
  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.

Challenge-and-signature authentication: the private key stays on the client; the optional entry password is sent to the server.Challenge-and-signature authentication: the private key stays on the client; the optional entry password is sent to the server.

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):

  1. In AUTH_CHALLENGE, the server sends the nonce, its public key and a signature of monky-server-auth:v1:<server key>:<nonce>, proving it holds the key it presents.
  2. 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.
  3. 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 addressWhat the app does
Never presented a keySigns the nonce, as before (older servers keep working)
First key, not pinned by any other saved serverSigns bound to it and pins it on joining
The same pinned keySigns bound to it
Another keyRefuses (Key changed). Only a new invitation carrying the new key replaces it
No key, after presenting oneRefuses: there is no downgrade to the old format
The key of another saved serverRefuses, 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.

TableHolds
server_metaServer configuration: name, password, owner, limits, icon
server_identityThe server's Ed25519 identity key (signature binding)
usersMembers, each with their public key
channelsVoice and text channels
messagesChat history
mentionsMentions, for highlighting and notifying
message_attachmentsMessage attachments
roles / user_rolesRoles and who holds each one
schema_migrationsBookkeeping 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 ​

ProtectionHow
Message floodSliding window: 10 messages every 5 s
Message size16,000 characters by default; configurable per server, 0 disables the character limit; WebSocket packets remain limited to 8 MiB
Avatar5 MB, and the file must carry a PNG, JPEG or WebP signature
AttachmentsPer-file limit and a total server budget, both configurable
SoundboardAudio refused above ~4 MB
Path traversalFile 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.

Topology: full meshTopology: full mesh

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 the mediasoup worker.

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.

SignalingSignaling

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 ​

AudioAudio

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:

PlatformHow
WindowsWASAPI process loopback — captures system sound or a specific app's, excluding Monky itself so it does not echo
macOSScreenCaptureKit (macOS 13+ for audio; native video requires 14+), capturing system audio or the entire selected application after confirmation
OthersNot supported — the app keeps working, just without screen audio
Web versionThe 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:

ProfileAudioCameraScreen
Light24 kbps640×360 @ 30fps · 300 kbps640×360 @ 30fps · 700 kbps
Standard32 kbps854×480 @ 30fps · 500 kbps1280×720 @ 30fps · 2500 kbps
Sharp48 kbps1280×720 @ 30fps · 1000 kbps1920×1080 @ 30fps · 4500 kbps
Smooth64 kbps1920×1080 @ 30fps · 2000 kbps1920×1080 @ 60fps · 8000 kbps
Ultra64 kbps1920×1080 @ 30fps · 2000 kbps2560×1440 @ 60fps · 12000 kbps
Cinema64 kbps1920×1080 @ 30fps · 2000 kbps3840×2160 @ 60fps · 25000 kbps
Extreme64 kbps1920×1080 @ 30fps · 2000 kbps3840×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.

ReconnectionReconnection

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_PROTOCOL for clients, MIN_BOT_PROTOCOL for 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 ​