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)
- Desktop sends a DaySnapshot with your whole day: focus, tasks, echoes, inbox, stones, active pulse
- Companion receives and applies the data to its SQLite cache
- Companion sends deltas when you make changes: complete subtask, add to inbox, start pulse
- Desktop receives and applies the deltas to your vault
QR Pairing
- On Desktop: Settings > Sync > “Generate QR”
- On Companion: open the app and point the camera at the QR
- Pairing establishes automatically
- Data starts syncing
What’s exchanged? An encrypted session key. The relay only sees the session_id (a UUID), never the content.
Security
| Layer | What it protects |
|---|---|
| NaCl secretbox | Symmetric encryption of all WebSocket messages |
| Session key | Derived from QR, stored in SecureStore (Companion) |
| Stateless relay | Doesn’t persist messages, doesn’t store data, doesn’t parse content |
| E2EE | Server 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
revoketo unlink the session permanently. - Unlink: A device can send
unlinkto 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
| Variable | Default | Description |
|---|---|---|
PORT | 3005 | Relay listening port |
STATE_AUTH_SECRET | '' | If set, HTTP /state requires X-Monolith-Secret header |
Deployment (Dokploy)
- Push this repo to GitHub
- In Dokploy, create a new service → point to the repo
- Set environment variables:
PORT=3005,STATE_AUTH_SECRET=<your-secret> - 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)
| Type | When |
|---|---|
session_paired | Second device joined or first device joined (waiting) |
peer_connected | Peer (re)connected |
peer_disconnected | Peer disconnected |
peer_revoked | Session revoked by the other peer |
device_unlinked | Peer sent unlink |
server_shutdown | Server shutting down (graceful) |
Client messages
| Type | Action |
|---|---|
join | Identify and create/join session |
unlink | Graceful disconnect without revocation |
revoke | Permanently revoke the session (other peer gets peer_revoked) |
All other messages are transparently forwarded to the peer.
HTTP Endpoints
| Endpoint | Description |
|---|---|
GET /health | Server 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 /state | Desktop publishes pulse state for a session |
What data syncs
| Direction | Data |
|---|---|
| Desktop → Companion | DaySnapshot: focus, today’s tasks, echoes, inbox, stones, active pulse |
| Companion → Desktop | Deltas: 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.