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
WebSockethandles pong responses automatically. - Reconnection: The platform
WebSocketdoes not auto-reconnect. Implement reconnection logic with exponential backoff (1s → 2s → 4s → 8s, capped at 60s) in yourcloseevent handler. After reconnect, refetch any missed state via REST using the last-seentick_idfrom 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 a1009 (Message Too Big)close. Excessive client send rate triggers a1008 (Policy Violation)close.