Home / Guide / Sync

Sync

Desktop and mobile, always in sync. QR pairing, E2EE, relay.

Sync — Desktop and mobile, always in sync

Monolith Desktop is the source of truth. The Companion receives your data via end-to-end encrypted WebSocket. The relay server never sees your information — it just forwards bytes between your devices.

What for? Working on mobile (capturing ideas, viewing your agenda, using Pulse) without losing anything. Everything goes back to Desktop.


How it works

Desktop (vault .md)

Desktop Electron App
    ↕ (DaySnapshot / encrypted deltas)
Relay Server (WebSocket)

Companion App (SQLite cache)
  1. Desktop sends a DaySnapshot with your whole day: focus, tasks, echoes, inbox, stones, active pulse
  2. Companion receives and applies the data to its SQLite cache
  3. Companion sends deltas when you make changes: complete subtask, add to inbox, start pulse
  4. Desktop receives and applies the deltas to your vault

QR Pairing

  1. On Desktop: Settings > Sync > “Generate QR”
  2. On Companion: open the app and point the camera at the QR
  3. Pairing establishes automatically
  4. Data starts syncing

What’s exchanged? An encrypted session key. The relay only sees the session_id (a UUID), never the content.


Security

LayerWhat it protects
NaCl secretboxSymmetric encryption of all WebSocket messages
Session keyDerived from QR, stored in SecureStore (Companion)
Stateless relayDoesn’t persist messages, doesn’t store data, doesn’t parse content
E2EEServer only forwards encrypted bytes. Never sees plaintext

The relay

Monolith Relay is a WebSocket server that connects Desktop with Companion. Messages are end-to-end encrypted — the relay only forwards bytes between paired peers. Zero dependencies (pure Bun runtime).

GitHub Repository: monolith-relay

Architecture

Desktop ──WS──> Monolith Relay ──WS──> Companion
                    |
               /state HTTP API
                    |
          (Pulse state cache)
  • Sessions: Two devices (desktop + companion) share a session identified by a UUID v4.
  • Pairing: First device joins (waiting). Second device joins (paired). Both get session_paired.
  • Forwarding: Messages from one peer are forwarded to the other. If the peer is offline, messages queue (FIFO, max 1000).
  • State API: Desktop publishes pulse state via HTTP POST /state. Companion reads it via GET /state (for widget refresh without WebSocket).
  • Reconnection: If a device reconnects with the same device_id, the old socket is replaced (code 4014).
  • Revocation: A device can send revoke to unlink the session permanently.
  • Unlink: A device can send unlink to gracefully disconnect without revoking.

Quick Start

git clone https://github.com/OzkrRouj/monolith-relay
cd monolith-relay
bun install
bun run dev    # development with --watch
bun run start  # production

Environment Variables

VariableDefaultDescription
PORT3005Relay listening port
STATE_AUTH_SECRET''If set, HTTP /state requires X-Monolith-Secret header

Deployment (Dokploy)

  1. Push this repo to GitHub
  2. In Dokploy, create a new service → point to the repo
  3. Set environment variables: PORT=3005, STATE_AUTH_SECRET=<your-secret>
  4. Expose port 3005 (Traefik handles SSL termination)

Protocol

Join

Client sends immediately after WebSocket connects:

{ "type": "join", "session_id": "<uuid-v4>", "device_id": "<unique-per-device>", "version": 1 }

Relay messages (server → client)

TypeWhen
session_pairedSecond device joined or first device joined (waiting)
peer_connectedPeer (re)connected
peer_disconnectedPeer disconnected
peer_revokedSession revoked by the other peer
device_unlinkedPeer sent unlink
server_shutdownServer shutting down (graceful)

Client messages

TypeAction
joinIdentify and create/join session
unlinkGraceful disconnect without revocation
revokePermanently revoke the session (other peer gets peer_revoked)

All other messages are transparently forwarded to the peer.

HTTP Endpoints

EndpointDescription
GET /healthServer status, version, session counts
GET /state?sessionId=<uuid>Cached pulse state for a session (requires X-Monolith-Secret if STATE_AUTH_SECRET is set)
POST /stateDesktop publishes pulse state for a session

What data syncs

DirectionData
Desktop → CompanionDaySnapshot: focus, today’s tasks, echoes, inbox, stones, active pulse
Companion → DesktopDeltas: subtask_complete, inbox_append, inbox_delete, inbox_update, pulso_start, pulso_end, foco_change, task_activate, roca_changed

Rule: Desktop always has priority (LWW — Last Writer Wins). If there’s a conflict, Desktop wins.


Offline

The Companion maintains a delta queue in AsyncStorage. When you reconnect, pending deltas are sent automatically. You don’t lose anything if you were offline.

Background Fetch runs a sync cycle every 15 minutes even when the app is closed.