Skip to content

WebSocket Events Reference

Multiverse Echoes provides real-time event streaming via five WebSocket endpoints. JWT auth is via the ?token=<jwt> query parameter (browsers cannot set custom headers on the WebSocket handshake). Subscription is implicit from the URL path — the server does not parse client-sent action frames.

Connection Endpoints

Stream URL Description Frame envelope
Echo wss://api.echolabsme.com/ws/echoes/{echo_id}/stream?token=<jwt> Events for a specific Echo WsEchoEvent (top-level type discriminator)
Shard wss://api.echolabsme.com/ws/shards/{shard_id}/stream?token=<jwt> All WorldEvents for a Shard WsShardWorldEventFrame (different envelope — see below)
Channel wss://api.echolabsme.com/ws/channels/{channel_id}/stream?token=<jwt> Messages in a community channel WsEchoEvent (filtered to CommunityMessage* variants)
Community wss://api.echolabsme.com/ws/community/stream?token=<jwt> Community-wide message feed WsEchoEvent (filtered to CommunityMessagePosted)
Dashboard wss://api.echolabsme.com/ws/dashboard/stream?token=<jwt> All-Echo events for the user (plus billing-health variants) WsEchoEvent

Per-user connection cap: 5 concurrent WebSocket connections (MAX_WS_CONNECTIONS_PER_USER).

Frame Catalogue

Handshake (first frame on every stream)

The first frame on every stream is a handshake whose shape does NOT match any WsEchoEvent variant. It carries stream-specific identifiers and (for echo / dashboard streams) timing metadata for tick reconciliation.

// /ws/echoes/{id}/stream first frame
{"type": "ConnectionEstablished", "echo_id": "uuid", "message": "...", "last_tick_at": 1730000000, "tick_interval": 60}

// /ws/shards/{shard_id}/stream first frame
{"type": "Connected", "shard_id": "uuid", "message": "..."}

// /ws/channels/{channel_id}/stream first frame
{"type": "Connected", "channel_id": "uuid", "message": "..."}

// /ws/community/stream first frame
{"type": "Connected", "message": "..."}

// /ws/dashboard/stream first frame
{"type": "ConnectionEstablished", "message": "...", "last_tick_at": 1730000000, "tick_interval": 60}

WsEchoEvent — echo / channel / community / dashboard streams

Every subsequent frame on these four streams is a tagged-union JSON value with the discriminator at the top level: type is a direct field on the frame, NOT nested under payload. Frames are flat — there is no payload wrapper.

The complete variant list (per crates/api/src/routes/websocket.rs::WsEchoEvent):

{"type": "DiaryEntryCreated", "echo_id": "uuid", "diary_id": "uuid", "tick_id": 142, "content_locale": "en"}
{"type": "DiaryImageReady", "echo_id": "uuid", "diary_id": "uuid", "image_url": "..."}
{"type": "LifeEventOccurred", "echo_id": "uuid", "event_id": "uuid", "tick_id": 142, "content_locale": "en"}
{"type": "MoodChanged", "echo_id": "uuid", "mood": "Curious", "tick_id": 142}
{"type": "EchoMoved", "echo_id": "uuid", "from_location": "...", "to_location": "..."}
{"type": "PersonaUpdated", "echo_id": "uuid", "version": 3}
{"type": "EchoHibernated", "echo_id": "uuid", "reason": "..."}
{"type": "EchoWoken", "echo_id": "uuid"}
{"type": "EchoWealthChanged", "echo_id": "uuid", "old_value": 100, "new_value": 150}
{"type": "EchoDeleted", "echo_id": "uuid"}
{"type": "ShardTravelCompleted", "echo_id": "uuid", "shard_id": "uuid"}
{"type": "ShardCreated", "shard_id": "uuid", "shard_type": "..."}
{"type": "CommunityMessagePosted", "channel_id": "uuid", "message_id": "uuid", "author_id": "uuid"}
{"type": "CommunityMessageEdited", "channel_id": "uuid", "message_id": "uuid", "author_id": "uuid"}
{"type": "CommunityMessageDeleted", "channel_id": "uuid", "message_id": "uuid", "deleted_by": "uuid"}
{"type": "NotificationCreated", "notification_id": "uuid"}
{"type": "EchoAvatarReady", "echo_id": "uuid", "avatar_url": "..."}
{"type": "ShareTokenRevoked", "token": "...", "revoked_by_user_id": "uuid|null", "reason": "..."}  // dashboard-only
{"type": "Connected", "echo_id": "uuid", "message": "..."}                                          // shard-only Connected variant
{"type": "Error", "message": "..."}
{"type": "PaymentFailed", "provider": "nowpayments|xaman", "attempt_number": 1}                     // dashboard-only
{"type": "DunningPhaseChanged", "provider": "nowpayments|xaman", "from": "Active", "to": "GraceHardWarning"}  // dashboard-only
{"type": "RevenueSnapshotGenerated", "snapshot_date": "2026-05-02"}                                 // dashboard-only (admin)

WsShardWorldEventFrame — shard stream

The shard stream emits a different envelope (/ws/shards/{shard_id}/stream). It does NOT use the flat WsEchoEvent discriminator; instead it emits the raw me_core::event_bus::WorldEvent envelope with an externally-tagged payload — a single-key object whose key is the variant name.

{
  "event_id": "01234567-89ab-cdef-0123-456789abcdef",
  "tick_id": 1042,
  "timestamp": "2026-03-25T14:30:00Z",
  "payload": {
    "DiaryEntryGenerated": { "echo_id": "uuid", "diary_id": "uuid", "...": "..." }
  }
}

The full enumeration of payload keys lives in crates/core/src/event_bus.rs::WorldEventPayload (~50 variants). Branch on the single key in payload:

const event = JSON.parse(frame.data);
const variant = Object.keys(event.payload)[0];
const data = event.payload[variant];

switch (variant) {
  case 'DiaryEntryGenerated':
    console.log(`Tick ${event.tick_id}: ${data.echo_id} diary ${data.diary_id}`);
    break;
  // ...
}

Direct WebSocket Usage

The platform WebSocket API is the canonical access path. A typed SDK lives at client/packages/sdk/src/websocket.ts on the public mirror; the package is not yet on npm — use raw WebSocket for browser/Node integration until then.

// Subscribe to Echo events
const ws = new WebSocket(
  `wss://api.echolabsme.com/ws/echoes/${echoId}/stream?token=${accessToken}`,
);

ws.addEventListener('message', (frame) => {
  const event = JSON.parse(frame.data);

  // First frame is the handshake
  if (event.type === 'ConnectionEstablished') {
    console.log(`Subscribed; last tick at ${event.last_tick_at}`);
    return;
  }

  // Subsequent frames — top-level `type` discriminator (NOT nested under payload)
  switch (event.type) {
    case 'DiaryEntryCreated':
      console.log('New diary entry:', event.diary_id);
      break;
    case 'MoodChanged':
      console.log('Mood:', event.mood);
      break;
    case 'NotificationCreated':
      console.log('Notification:', event.notification_id);
      break;
  }
});

ws.addEventListener('close', () => {
  console.log('Disconnected');
});

ws.addEventListener('error', (err) => {
  console.error('WebSocket error:', err);
});

// Later: close the connection
ws.close();

Connection Behaviour

  • Subscription model: implicit from URL path. The server does not parse {action: "subscribe"} or any other client-sent action frame; client frames are rate-limited and size-capped, but otherwise ignored at the application layer.
  • Authentication: JWT via ?token=<jwt> query parameter. Connections with invalid, expired, or blocklisted tokens are rejected during the upgrade handshake.
  • Ping/pong: The server sends periodic ping frames; the platform WebSocket handles pong responses automatically.
  • Reconnection: The platform WebSocket does not auto-reconnect. Implement reconnection logic with exponential backoff (1s → 2s → 4s → 8s, capped at 60s) in your close event handler. After reconnect, refetch any missed state via REST using the last-seen tick_id from the most recent frame (e.g. GET /echoes/{id}/diary?since_tick=...).
  • Event ordering: Events are delivered in tick order within a single connection. Cross-connection ordering is not guaranteed.
  • Per-user cap: 5 concurrent WebSocket connections per user (MAX_WS_CONNECTIONS_PER_USER). Excess connections are rejected during the upgrade handshake.
  • Frame size cap + rate limit: client-sent frames are capped at MAX_CLIENT_FRAME_BYTES; oversize frames trigger a 1009 (Message Too Big) close. Excessive client send rate triggers a 1008 (Policy Violation) close.