WebSocket Events

LVNG uses Socket.io for real-time communication. Connect to receive live messages, typing indicators, presence updates, conversation events, call transcription, and swarm session updates.

Connection

Connect to the Socket.io server with your JWT token for authentication. The server supports both WebSocket (preferred) and HTTP long-polling as a fallback.

connection.ts
import { io } from 'socket.io-client';

const socket = io('wss://api.lvng.ai', {
  auth: {
    token: 'YOUR_JWT_TOKEN'
  },
  transports: ['websocket', 'polling']
});

// Connection established
socket.on('connected', (data) => {
  console.log('Connected:', data);
  // { socketId, userId, organizationId, timestamp }
});

// Handle errors
socket.on('error', (err) => {
  console.error('Socket error:', err);
  // { code, message }
});

socket.on('disconnect', (reason) => {
  console.log('Disconnected:', reason);
});
ParameterValue
Serverwss://api.lvng.ai
AuthJWT token passed in the auth.token handshake field
TransportsWebSocket (preferred), HTTP long-polling (fallback)
Ping interval25000 ms (25s)
Ping timeout60000 ms (60s)
Max buffer size1e6 (1 MB)
Rate limit100 events per 60 seconds per socket

Note: You must join a channel with channel:join or a conversation with conversation:join before receiving events for that room.

Room Structure

Events are scoped to rooms. The server automatically manages room membership based on your join/leave actions. Each room type uses a specific naming convention.

Room PatternScope
org:{organizationId}Organization-wide events
presence:{workspaceId}Presence of a workspace's members, joined for each workspace you are a member of
user:{userId}Personal room for user-targeted events (call notifications, OODA updates)
channel:{channelId}Channel messages, typing, and member events
customer:{customerId}:conversation:{conversationId}Multi-tenant DMs and group conversations
call:{roomName}Call participants and live transcription
swarm:{sessionId}Agent swarm session subscribers
activity:{customerId}Activity feed for a customer

Messages

Send and receive messages in real-time. When you emit message:send, the server validates the payload, persists the message, and broadcasts message:new to the room. The sender also receives a message:sent acknowledgment with the server-assigned ID mapped to your optimisticId.

messages.ts
// Send a message to a channel
socket.emit('message:send', {
  channelId: '550e8400-e29b-41d4-a716-446655440000',
  content: 'Hello from WebSocket!',
  optimisticId: 'tmp_abc123',          // Optional, for optimistic UI
  threadId: null,                       // Optional, for threaded replies
  mentions: ['usr_8f3a2b1c-4d5e-...'], // Optional
  workspaceId: 'ws_b2c3d4e5-...'       // Optional
});

// Confirmation sent back to sender only
socket.on('message:sent', (ack) => {
  console.log('Confirmed:', ack);
  // { id, optimisticId, channelId, timestamp }
});

// New message broadcast to the room
socket.on('message:new', (message) => {
  console.log('New message:', message);
  // { id, channelId, conversationId, userId, userName,
  //   content, contentType, threadId, attachments,
  //   createdAt, updatedAt, metadata }
});

// Message edited
socket.on('message:update', (update) => {
  console.log('Updated:', update);
  // { messageId, content, metadata, channelId,
  //   conversationId, updatedBy, timestamp }
});

// Message deleted
socket.on('message:delete', (data) => {
  console.log('Deleted:', data);
  // { messageId, channelId, conversationId,
  //   deletedBy, deletedByName, timestamp }
});

// Reaction added or removed
socket.on('message:reaction', (reaction) => {
  console.log('Reaction:', reaction);
  // { messageId, channelId, emoji, userId,
  //   userName, action: 'add'|'remove', timestamp }
});

// Read receipt
socket.on('message:read', (receipt) => {
  console.log('Read:', receipt);
  // { messageId, channelId, conversationId,
  //   userId, userName, timestamp }
});

// Batch read receipts
socket.on('message:read:batch', (batch) => {
  console.log('Batch read:', batch);
  // { messageIds: [], channelId, conversationId,
  //   userId, timestamp }
});

Message Events

EventDirectionPayload
message:sendClient → Server{channelId, conversationId?, customerId?, content, threadId?, mentions?, optimisticId?, workspaceId?}
message:newServer → Client{id, channelId, conversationId, userId, userName, content, contentType, threadId, attachments, createdAt, updatedAt, metadata}
message:sentServer → Client{id, optimisticId, channelId, timestamp}
message:updateClient → Server{messageId, channelId, conversationId?, customerId?, content, metadata?}
message:updateServer → Client{messageId, content, metadata, channelId, conversationId, updatedBy, timestamp}
message:deleteClient → Server{messageId, channelId, conversationId?, customerId?}
message:deleteServer → Client{messageId, channelId, conversationId, deletedBy, deletedByName, timestamp}
message:reactClient → Server{messageId, channelId, emoji, action: "add"|"remove"}
message:reactionServer → Client{messageId, channelId, emoji, userId, userName, action, timestamp}
message:readClient → Server{messageId, channelId, conversationId?, customerId?}
message:readServer → Client{messageId, channelId, conversationId, userId, userName, timestamp}
message:read:batchServer → Client{messageIds: [], channelId, conversationId, userId, timestamp}
message:send_queuedClient → Server{messages: [{channelId, content, ...}]}

Typing Indicators

Emit typing:start when the user begins typing and typing:stop when they pause or clear the input. The server rebroadcasts the event to all other members of the channel or conversation room.

typing.ts
// Start typing indicator
socket.emit('typing:start', {
  channelId: '550e8400-e29b-41d4-a716-446655440000',
  userId: 'usr_8f3a2b1c-...',
  userName: 'Sam'
});

// Stop typing indicator
socket.emit('typing:stop', {
  channelId: '550e8400-e29b-41d4-a716-446655440000',
  userId: 'usr_8f3a2b1c-...',
  userName: 'Sam'
});

// Listen for others typing
socket.on('typing:start', (data) => {
  console.log(data.userName, 'is typing in', data.channelId);
  // { channelId, conversationId, userId, userName, timestamp }
});

socket.on('typing:stop', (data) => {
  console.log(data.userName, 'stopped typing');
  // { channelId, conversationId, userId, userName, timestamp }
});

Typing Events

EventDirectionPayload
typing:startClient → Server{channelId, conversationId?, customerId?, userId, userName}
typing:startServer → Client{channelId, conversationId, userId, userName, timestamp}
typing:stopClient → Server{channelId, conversationId?, customerId?, userId, userName}
typing:stopServer → Client{channelId, conversationId, userId, userName, timestamp}

Channels

Join a channel room to receive its messages, typing indicators, and update events. To get its last 50 messages as channel:history, join with history: true; a join without it sends no history. DM and group rooms never send channel:history. Both channel:join and channel:leave support Socket.io callback acknowledgments.

channels.ts
// Join a channel room to receive its events.
// history: true also asks for its recent messages (channel:history below).
// Leave it out and no history is sent.
socket.emit('channel:join', { channelId: '550e8400-...', history: true }, (response) => {
  console.log('Joined channel:', response);
});

// Server broadcasts join to other members
// Other clients receive:
// { channelId, userId, userName, user: { id, name }, timestamp }

// Sent to the joining client only when its join set history: true
socket.on('channel:history', (data) => {
  console.log('History:', data);
  // { channelId, messages: [...], hasMore, total }
  // Last 50 messages by default
});

// Leave a channel room
socket.emit('channel:leave', { channelId: '550e8400-...' }, (response) => {
  console.log('Left channel:', response);
});

// Listen for channel metadata updates
socket.on('channel:update', (data) => {
  console.log('Channel updated:', data);
  // { channelId, updates, channel, updatedBy, updatedByName, timestamp }
});

Channel Events

EventDirectionPayload
channel:joinClient → Server{channelId, history?: true} with callback
channel:joinServer → Client{channelId, userId, userName, user: {id, name}, timestamp}
channel:historyServer → Client{channelId, messages: [], hasMore, total} (only after a join with history: true)
channel:leaveClient → Server{channelId} with callback
channel:leaveServer → Client{channelId, userId, userName, timestamp}
channel:updateClient → Server{channelId, updates: {...}}
channel:updateServer → Client{channelId, updates, channel, updatedBy, updatedByName, timestamp}

Presence

A person is online while a connection of theirs is in use (or in a call), away when all of their connections are idle, and offline 15 seconds after their last connection closes. The server works this out from every connection; a client reports only its own activity. Changes go to the presence:{workspaceId} room of each workspace the person is in, so only members of a shared workspace see them. The in-a-call mark says only that someone is in a call, never which one.

presence.ts
// Keep this connection counted (every 30 s; it lapses after 60 s)
socket.emit('presence:heartbeat');

// Say whether this tab is in use: 'idle' after 10 minutes without input
// or 30 s hidden, 'active' again on input. Send it on every connect too.
socket.emit('presence:activity', { state: 'active' });

// Everyone in a workspace you are a member of, now
socket.emit('presence:subscribe', { workspaceId }, (answer) => {
  // { success: true, workspaceId, members: [{ userId, status, inCall, rev, displayName, avatarUrl }] }
  // or { success: false, error: 'NOT_A_MEMBER' | 'INVALID_WORKSPACE' | 'RATE_LIMITED' | 'UNAVAILABLE' }
});

// Changes, from the members of your workspaces
socket.on('presence:update', (data) => {
  // { userId, userName, status: 'online' | 'away' | 'offline', inCall, rev, timestamp }
  // Keep the highest rev per person; a snapshot replaces what you had before asking.
});
socket.on('presence:joined', ({ workspaceId, member }) => {});
socket.on('presence:left', ({ workspaceId, userId }) => {});

Presence Events

EventDirectionPayload
presence:heartbeatClient → Server(empty)
presence:activityClient → Server{state: "active"|"idle"}
presence:subscribeClient → Server{workspaceId} (ack: {success, workspaceId, members} or {success: false, error})
presence:updateServer → Client{userId, userName, status: "online"|"away"|"offline", inCall, rev, timestamp}
presence:joinedServer → Client{workspaceId, member: {userId, status, inCall, rev, displayName, avatarUrl}}
presence:leftServer → Client{workspaceId, userId}

Conversations

Multi-tenant conversations use a customer:{customerId}:conversation:{conversationId} room pattern. The conversation:rejoin event lets you reconnect after a disconnect and retrieve any missed messages since your last known message.

conversations.ts
// Join a multi-tenant conversation
socket.emit('conversation:join', {
  conversationId: 'conv_a1b2c3d4-...',
  customerId: 'cust_e5f6a7b8-...'
});

// Confirmation sent to sender
socket.on('room:joined', (data) => {
  console.log('Joined room:', data);
  // { room, conversationId, customerId, timestamp }
});

// Other members in the room see:
// member:joined -> { conversationId, userId, userName, timestamp }

// Leave a conversation
socket.emit('conversation:leave', {
  conversationId: 'conv_a1b2c3d4-...',
  customerId: 'cust_e5f6a7b8-...'
});

// Rejoin after disconnect (get missed messages)
socket.emit('conversation:rejoin', {
  conversationId: 'conv_a1b2c3d4-...',
  customerId: 'cust_e5f6a7b8-...',
  lastMessageId: 'msg_f7e6d5c4-...',       // Optional
  lastMessageTimestamp: '2026-03-19T12:00Z' // Optional
});

socket.on('conversation:rejoined', (data) => {
  console.log('Missed messages:', data.missed_messages);
  // { conversationId, customerId, missed_messages: [], timestamp }
});

Conversation Events

EventDirectionPayload
conversation:joinClient → Server{conversationId, customerId}
room:joinedServer → Client{room, conversationId, customerId, timestamp}
member:joinedServer → Client{conversationId, userId, userName, timestamp}
conversation:leaveClient → Server{conversationId, customerId}
member:leftServer → Client{conversationId, userId, userName, timestamp}
conversation:rejoinClient → Server{conversationId, customerId, lastMessageId?, lastMessageTimestamp?}
conversation:rejoinedServer → Client{conversationId, customerId, missed_messages: [], timestamp}
conversation:mark_readClient → Server{conversationId, customerId}

Calls

Call rooms use the call:{roomName} pattern. Participants can send live transcription segments which are broadcast to all other participants in the call.

calls.ts
// Join a call room
socket.emit('call:join', { roomName: 'call_engineering_standup' });

socket.on('call:joined', (data) => {
  console.log('Joined call:', data);
  // { roomName, timestamp }
});

// Others in the call receive:
// call:participant_joined -> { roomName, userId, userName, timestamp }

// Send live transcription segments
socket.emit('call:transcription', {
  roomName: 'call_engineering_standup',
  segment: {
    speaker: 'Sam',
    text: 'Let us review the sprint backlog.',
    isFinal: true,
    confidence: 0.97
  }
});

// Receive transcription segments from others
socket.on('transcription:segment', (segment) => {
  console.log(segment.speaker + ':', segment.text);
  // { speaker, text, isFinal, timestamp, confidence }
});

// Leave the call
socket.emit('call:leave', { roomName: 'call_engineering_standup' });

// Others receive:
// call:participant_left -> { roomName, userId, userName, reason, timestamp }

Call Events

EventDirectionPayload
call:joinClient → Server{roomName}
call:joinedServer → Client{roomName, timestamp}
call:participant_joinedServer → Client{roomName, userId, userName, timestamp}
call:leaveClient → Server{roomName}
call:participant_leftServer → Client{roomName, userId, userName, reason, timestamp}
call:declineClient → Server{roomId, callerId}
call:declinedServer → Client{roomId, declinedBy, declinedByName, timestamp}
call:cancelClient → Server{roomId, recipientId?, roomName?}
call:cancelledServer → Client{roomId, roomName?, cancelledBy, cancelledByName, timestamp}
call:transcriptionClient → Server{roomName, segment}
call:audio-chunkClient → Server{roomName, chunk}
transcription:segmentServer → Client{speaker, text, isFinal, timestamp, confidence}
transcription:resultServer → Client{roomName, text, isFinal, confidence, timestamp}

Swarm Sessions

Subscribe to a swarm session to receive real-time updates as agents in the swarm complete steps. The server joins you to the swarm:{sessionId} room.

swarm.ts
// Subscribe to a swarm session's updates
socket.emit('swarm:subscribe', {
  sessionId: 'swarm_9a8b7c6d-...'
});

socket.on('swarm:subscribed', (data) => {
  console.log('Subscribed to swarm:', data.sessionId);
  // { sessionId }
});

// Unsubscribe when done
socket.emit('swarm:unsubscribe', {
  sessionId: 'swarm_9a8b7c6d-...'
});

Swarm Events

EventDirectionPayload
swarm:subscribeClient → Server{sessionId}
swarm:subscribedServer → Client{sessionId}
swarm:unsubscribeClient → Server{sessionId}

Activity Feed

Join a tenant-isolated activity feed room to receive real-time activity events for your workspace. The room format is activity:{customerId}.

activity.ts
// Join an activity feed room for your workspace
socket.emit('activity:join', {
  room: 'activity:cust_e5f6a7b8-...'
});

socket.on('activity:joined', (data) => {
  console.log('Joined activity feed:', data.room);
  // { room }
});

// Leave the activity feed
socket.emit('activity:leave', {
  room: 'activity:cust_e5f6a7b8-...'
});

Activity Events

EventDirectionPayload
activity:joinClient → Server{room: "activity:{customerId}"}
activity:joinedServer → Client{room}
activity:leaveClient → Server{room}

OODA Loop

When an OODA loop agent executes via the streaming API, phase updates are emitted to the user's personal user:{userId} room. Each phase (observe, orient, decide, act) emits start and completion events.

ooda.ts
// OODA loop phase events are emitted during streaming execution
// Subscribe by joining the user room (automatic on connection)

socket.on('ooda:phase', (data) => {
  console.log('OODA phase:', data.phase, data.data.status);
  // { phase: 'observe'|'orient'|'decide'|'act',
  //   iteration: 0, data: { status: 'started'|'completed', result? },
  //   timestamp }
});

socket.on('ooda:complete', (data) => {
  console.log('OODA complete:', data);
  // { goalMet: boolean, iterations: number,
  //   killed: boolean, timestamp }
});

OODA Events

EventDirectionPayload
ooda:phaseServer → Client{phase: "observe"|"orient"|"decide"|"act", iteration, data: {status, result?}, timestamp}
ooda:completeServer → Client{goalMet, iterations, killed, timestamp}

Tool Permissions

The agentic harness can request user approval before executing sensitive tools. When a tool requires permission, the server emits a request event and waits up to 5 minutes for a response. The request says when it ends (expiresAt, remainingMs); the server ends it then and the tool does not run. Only the first answer in time counts. After a page reload, a signed-in session reads the requests still waiting for it with GET /api/tools/approvals/pending (each entry has the same fields as the request event) and answers them over the socket. A request raised while Tron replies in a conversation (a channel, a DM, a group or a twin chat) names it: channelId and conversationId hold that conversation's id, so any window can show the request there. A request from anywhere else has neither field.

permissions.ts
// Tool permission events for agentic harness approval flows
socket.on('tool:permission:request', (data) => {
  console.log('Tool needs approval:', data.toolName);
  // { requestId, toolName, input, reason, summary?,
  //   channelId?, conversationId?,
  //   createdAt, expiresAt, timeoutMs, remainingMs }
  // channelId and conversationId: the conversation Tron was asked in, when there is one.
  // remainingMs is measured by the server: count down from it.
  // The same request can be announced more than once: keep one per requestId.

  // Respond with approval or denial. The acknowledgement says what the answer did.
  socket.emit('tool:permission:response', {
    requestId: data.requestId,
    approved: true
  }, (ack) => {
    // { requestId, ok: true, status: 'approved' | 'denied' }
    // { requestId, ok: false, code: 'expired' | 'already_answered', status }
    // { requestId, ok: false, code: 'relayed' | 'not_found' | 'invalid' }
  });
});

// How a request ended, sent to every connection of the user
socket.on('tool:permission:resolved', (data) => {
  // { requestId, toolName, status: 'approved' | 'denied' | 'expired', resolvedAt }
});

Tool Permission Events

EventDirectionPayload
tool:permission:requestServer → Client{requestId, toolName, input, reason, summary?, channelId?, conversationId?, createdAt, expiresAt, timeoutMs, remainingMs}
tool:permission:responseClient → Server{requestId, approved: boolean, reason?}, acknowledged with {requestId, ok, status?, code?}
tool:permission:resolvedServer → Client{requestId, toolName, status: 'approved'|'denied'|'expired', resolvedAt}

Connection Events

These events are emitted by the server immediately after a successful or failed connection.

Connection Events

EventDirectionPayload
connectedServer → Client{socketId, userId, organizationId, timestamp}
errorServer → Client{code, message}

Reconnection: Socket.io handles automatic reconnection with exponential backoff. After reconnecting, re-join your channels and conversations, and use conversation:rejoin to fetch missed messages.