Mosaic exposes a single WebSocket fan-out at /api/ws. Connect once, stay connected, and receive a stream of typed frames as the engine ticks. There is no client-to-server message protocol — the server drains client reads for liveness but does nothing with them.

Upgrade

GET /api/ws
Auth Cookie OR ?key=<api-key> query param.
Origin Pinned to the request Host (OriginPatterns: []string{r.Host}).
TLS Use wss://. Plain ws:// only over loopback if the listener allows it.

Browser-side, the standard WebSocket constructor can’t set the Authorization header — pass the API key via the query param:

const ws = new WebSocket(`wss://localhost:8080/api/ws?key=${encodeURIComponent(key)}`);

If the cookie is already set on the origin, the browser will attach it to the upgrade automatically and you can drop the ?key=.

Envelope

Every frame is JSON of the form:

{ "type": "<discriminator>", "payload": <type-specific> }

type is a stable string. Unknown types should be ignored — Mosaic may add new ones in minor releases.

Frame types

torrents:tick

Emitted ~1 Hz. Full snapshot of every torrent. Use this to rerender the torrent list without polling REST.

Payload: TorrentDTO[]

{
  type: "torrents:tick",
  payload: [{
    id: string, name: string, magnet: string, save_path: string,
    total_bytes: number, bytes_done: number, progress: number,
    download_rate: number, upload_rate: number,
    peers: number, seeds: number,
    paused: boolean, completed: boolean,
    added_at: number,
    category_id: number | null,
    tags: { id: number, name: string, color: string }[],
    queue_position: number, force_start: boolean,
    queued: boolean, verifying: boolean, files_missing: boolean
  }, ...]
}

The list is sorted newest-first by added_at with id as tie-breaker — order is stable across ticks for the same torrents.

stats:tick

Emitted ~1 Hz. Aggregate counters for the status bar.

Payload: GlobalStats

{
  type: "stats:tick",
  payload: {
    total_torrents: number,
    active_torrents: number,
    seeding_torrents: number,
    total_download_rate: number,  // bytes/sec
    total_upload_rate: number,
    total_peers: number
  }
}

inspector:tick

Emitted ~1 Hz only when an inspector focus has been set via POST /api/inspector/focus. The payload’s optional fields (files, peers_list, trackers) are populated according to which tabs the focus call included.

Payload: DetailDTO

{
  type: "inspector:tick",
  payload: {
    id: string, name: string, magnet: string, save_path: string,
    total_bytes: number, bytes_done: number, progress: number,
    ratio: number, total_down: number, total_up: number,
    peers: number, seeds: number,
    added_at: number, completed_at?: number,
    files?: FileDTO[],          // present iff "files" was in the focus tabs
    peers_list?: PeerDTO[],     // present iff "peers" was in the focus tabs
    trackers?: TrackerDTO[]     // present iff "trackers" was in the focus tabs
  }
}

A focus is single-valued at the Service level — only the most recently set focus is active. Clearing it (POST /api/inspector/clear, or focus with empty id) stops the frames.

update:available

Emitted asynchronously when the background updater finds a newer release. Not periodic — fires once per check that returns available: true. Drives the in-app “update ready” toast.

Payload: UpdateInfoDTO

{
  type: "update:available",
  payload: {
    available: boolean,        // always true for this frame
    latest_version: string,
    asset_url: string,
    asset_filename: string,
    checked_at: number,        // unix seconds
    current_version: string
  }
}

Throughput and back-pressure

The per-client send channel is buffered to 64 frames. The hub’s broadcast loop drops frames for any client whose buffer is full rather than blocking the producer. There is no replay or sequence number — clients catch up on the next tick.

Practically: a client that hangs on a long render briefly will see a few skipped ticks but reconverge automatically. A client that hangs for many seconds will receive a fresh torrents:tick (a full snapshot) the moment it consumes again, so the visible state is never stale.

Reconnect

Auth is checked at upgrade time only. The WS server does NOT re-validate the session cookie or the ?key= query param after the upgrade succeeds. Once the upgrade is accepted, the connection lives until the peer or the process closes it — even if the session cookie’s 12-hour TTL elapses, or if RotateAPIKey invalidates the key the upgrade used. This is a known limitation, not a bug; the hub does not track per-connection auth state across the connection lifetime.

The server will close the connection if:

  • The hub is shut down (process exit, or a web-config change that toggles the listener off / restarts it).
  • The peer closes (browser tab closed, network drop, server-side SetReadDeadline timeout from the per-client read pump).

Clients should reconnect with backoff and should also reconnect periodically (a once-an-hour reconnect is a reasonable default) to pick up any auth changes that happened mid-stream. After reconnecting, you’ll receive a fresh torrents:tick immediately on the next producer cycle — no need to call GET /api/torrents separately.

Practical guidance:

  • Reconnect on transport errors with exponential backoff capped at ~30 seconds.
  • Reconnect on a long timer (e.g. every hour) to refresh the auth context.
  • Reconnect explicitly after POST /api/settings/web/api_key/rotate or after a logout/login on the cookie path — the existing socket will keep working until you tear it down, but it’s holding onto a credential the rest of your app no longer recognizes.

Minimal browser client

function connect(key) {
  const ws = new WebSocket(`wss://${location.host}/api/ws?key=${encodeURIComponent(key)}`);
  ws.onmessage = (ev) => {
    const env = JSON.parse(ev.data);
    switch (env.type) {
      case 'torrents:tick':    onTorrents(env.payload); break;
      case 'stats:tick':       onStats(env.payload); break;
      case 'inspector:tick':   onInspector(env.payload); break;
      case 'update:available': onUpdate(env.payload); break;
    }
  };
  ws.onclose = () => setTimeout(() => connect(key), 2000);
}

See Worked Examples for a longer Python and curl flow.