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.
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);
});| Parameter | Value |
|---|---|
| Server | wss://api.lvng.ai |
| Auth | JWT token passed in the auth.token handshake field |
| Transports | WebSocket (preferred), HTTP long-polling (fallback) |
| Ping interval | 25000 ms (25s) |
| Ping timeout | 60000 ms (60s) |
| Max buffer size | 1e6 (1 MB) |
| Rate limit | 100 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 Pattern | Scope |
|---|---|
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.
// 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
| Event | Direction | Payload |
|---|---|---|
message:send | Client → Server | {channelId, conversationId?, customerId?, content, threadId?, mentions?, optimisticId?, workspaceId?} |
message:new | Server → Client | {id, channelId, conversationId, userId, userName, content, contentType, threadId, attachments, createdAt, updatedAt, metadata} |
message:sent | Server → Client | {id, optimisticId, channelId, timestamp} |
message:update | Client → Server | {messageId, channelId, conversationId?, customerId?, content, metadata?} |
message:update | Server → Client | {messageId, content, metadata, channelId, conversationId, updatedBy, timestamp} |
message:delete | Client → Server | {messageId, channelId, conversationId?, customerId?} |
message:delete | Server → Client | {messageId, channelId, conversationId, deletedBy, deletedByName, timestamp} |
message:react | Client → Server | {messageId, channelId, emoji, action: "add"|"remove"} |
message:reaction | Server → Client | {messageId, channelId, emoji, userId, userName, action, timestamp} |
message:read | Client → Server | {messageId, channelId, conversationId?, customerId?} |
message:read | Server → Client | {messageId, channelId, conversationId, userId, userName, timestamp} |
message:read:batch | Server → Client | {messageIds: [], channelId, conversationId, userId, timestamp} |
message:send_queued | Client → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
typing:start | Client → Server | {channelId, conversationId?, customerId?, userId, userName} |
typing:start | Server → Client | {channelId, conversationId, userId, userName, timestamp} |
typing:stop | Client → Server | {channelId, conversationId?, customerId?, userId, userName} |
typing:stop | Server → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
channel:join | Client → Server | {channelId, history?: true} with callback |
channel:join | Server → Client | {channelId, userId, userName, user: {id, name}, timestamp} |
channel:history | Server → Client | {channelId, messages: [], hasMore, total} (only after a join with history: true) |
channel:leave | Client → Server | {channelId} with callback |
channel:leave | Server → Client | {channelId, userId, userName, timestamp} |
channel:update | Client → Server | {channelId, updates: {...}} |
channel:update | Server → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
presence:heartbeat | Client → Server | (empty) |
presence:activity | Client → Server | {state: "active"|"idle"} |
presence:subscribe | Client → Server | {workspaceId} (ack: {success, workspaceId, members} or {success: false, error}) |
presence:update | Server → Client | {userId, userName, status: "online"|"away"|"offline", inCall, rev, timestamp} |
presence:joined | Server → Client | {workspaceId, member: {userId, status, inCall, rev, displayName, avatarUrl}} |
presence:left | Server → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
conversation:join | Client → Server | {conversationId, customerId} |
room:joined | Server → Client | {room, conversationId, customerId, timestamp} |
member:joined | Server → Client | {conversationId, userId, userName, timestamp} |
conversation:leave | Client → Server | {conversationId, customerId} |
member:left | Server → Client | {conversationId, userId, userName, timestamp} |
conversation:rejoin | Client → Server | {conversationId, customerId, lastMessageId?, lastMessageTimestamp?} |
conversation:rejoined | Server → Client | {conversationId, customerId, missed_messages: [], timestamp} |
conversation:mark_read | Client → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
call:join | Client → Server | {roomName} |
call:joined | Server → Client | {roomName, timestamp} |
call:participant_joined | Server → Client | {roomName, userId, userName, timestamp} |
call:leave | Client → Server | {roomName} |
call:participant_left | Server → Client | {roomName, userId, userName, reason, timestamp} |
call:decline | Client → Server | {roomId, callerId} |
call:declined | Server → Client | {roomId, declinedBy, declinedByName, timestamp} |
call:cancel | Client → Server | {roomId, recipientId?, roomName?} |
call:cancelled | Server → Client | {roomId, roomName?, cancelledBy, cancelledByName, timestamp} |
call:transcription | Client → Server | {roomName, segment} |
call:audio-chunk | Client → Server | {roomName, chunk} |
transcription:segment | Server → Client | {speaker, text, isFinal, timestamp, confidence} |
transcription:result | Server → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
swarm:subscribe | Client → Server | {sessionId} |
swarm:subscribed | Server → Client | {sessionId} |
swarm:unsubscribe | Client → 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}.
// 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
| Event | Direction | Payload |
|---|---|---|
activity:join | Client → Server | {room: "activity:{customerId}"} |
activity:joined | Server → Client | {room} |
activity:leave | Client → 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 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
| Event | Direction | Payload |
|---|---|---|
ooda:phase | Server → Client | {phase: "observe"|"orient"|"decide"|"act", iteration, data: {status, result?}, timestamp} |
ooda:complete | Server → 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.
// 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
| Event | Direction | Payload |
|---|---|---|
tool:permission:request | Server → Client | {requestId, toolName, input, reason, summary?, channelId?, conversationId?, createdAt, expiresAt, timeoutMs, remainingMs} |
tool:permission:response | Client → Server | {requestId, approved: boolean, reason?}, acknowledged with {requestId, ok, status?, code?} |
tool:permission:resolved | Server → 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
| Event | Direction | Payload |
|---|---|---|
connected | Server → Client | {socketId, userId, organizationId, timestamp} |
error | Server → 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.