Live chat goes over Socket.IO at namespace /chat. Customers, agents, and read-only broadcast viewers all share this namespace — server-side handlers gate per role.
Connection
Connect with { token, orgId } in the handshake auth. The token is a JWT issued by POST /profile/signin (agent/user) or POST /profile/customer/signin (customer).
import { io } from 'socket.io-client';
const socket = io('https://appengine.appmint.io/chat', {
transports: ['websocket'],
auth: {
token: '<jwt>',
orgId: '<your-org-id>',
deviceId: 'device-stable-uuid',
context: {
chatSessionId: localStorage.getItem('chatSessionId') || undefined,
configId: '<chat-config-id>',
currentUrl: window.location.href,
},
},
});
socket.on('authenticate', (result) => {
if (!result.success) return console.error(result.error);
if (result.chatSessionId) {
localStorage.setItem('chatSessionId', result.chatSessionId);
}
});
The server emits authenticate once it has verified the token. Failure causes an immediate disconnect.
Authentication response
{
success: true,
user: { email: '[email protected]', name: 'Alice', role: 'customer' },
chatSessionId: 'cs_abc...', // customer only
isGuest: false, // customer only
config: { // when configId was passed
configId: '...',
ai: '<assistantId>',
agents: [{ user: '[email protected]' }],
status: 'active',
defaultPath: '/'
}
}
For broadcast-only viewers (audio/video receive), pass { broadcastOnly: true, orgId, deviceId } in the handshake. No token required, no message permissions granted.
Events the client emits
chat-message — primary send path
socket.emit('chat-message', {
message: 'Hi, I need help with my order',
chatSessionId: '<session>', // customer
files: [], // optional uploads
assistantId: '<override>', // optional, defaults to session.assistantId
});
Behaviour depends on session state:
- AI path — config has an
aiassistant and no human is assigned. The server broadcasts the user message to the chat room, then streams AI tokens viachat-stream. - Agent path — a human agent is assigned (via
pick-nextortakeover-chat). The message is delivered asmessageto the agent's identity room. - Queue path — no AI, no agent. The customer is enqueued and the agent panels receive
queue-notification.
Other client events
| Event | Role | Purpose |
|---|---|---|
authenticate | any | Re-authenticate without disconnecting |
update-context | any | Update currentUrl, location, device, etc. |
set-status | agent | 'available' | 'busy' | 'away' |
list-online-agents | agent | List agents, optionally filter by skill |
pick-next | agent | Pop the next queued chat |
transfer-chat | agent | Hand the chat to another agent |
takeover-chat | agent | Take an AI conversation |
resume-ai | agent | Hand it back to the AI |
close-chat | agent | End the chat |
select-assistant | customer | Choose an AI assistant before sending |
list-assistants | any | Available assistants for this org |
list-chats | agent | Chat list for the admin panel |
chat-history | any | Replay messages for a chatId |
sendMessage | any | Peer-to-peer send (no AI) — staff DM, etc. |
join-chat / leave-chat | agent | Observe a room without claiming it |
archive-chat / unarchive-chat | agent | Lifecycle |
broadcast-start / broadcast-end | agent | Start a WebRTC broadcast to widgets |
engage-visitor | agent | Proactive chat invitation to a visitor |
webrtc-offer / webrtc-answer / ice-candidate | any | WebRTC signaling for broadcast |
Events the server emits
chat-stream — AI streaming
socket.on('chat-stream', (evt) => {
switch (evt.event) {
case 'chunk': appendText(evt.data); break;
case 'tool-use': showTool(evt.tool, evt.input); break;
case 'tool-result': showResult(evt.tool, evt.result); break;
case 'end': finalize(evt.sk); break;
case 'error': showError(evt.error); break;
}
});
Other server events
| Event | When |
|---|---|
message | A chat message arrived (system, user, ai-assistant, or peer) |
presence-change | A user came online / went offline / changed status |
agent-assigned | An agent picked up the customer's chat |
chat-ended | Chat closed (by anyone, or system inactivity) |
queued / queue-update | Customer's position update (every 15s while waiting) |
queue-notification | Agent panels — a customer is waiting |
queue-updated | Queue mutated (added, picked, removed) |
broadcast-started / broadcast-ended | WebRTC broadcast lifecycle |
Message shape
The server sends both flat and nested fields so widget and admin clients both work:
{
messageId: 'sk-abc',
sk: 'sk-abc',
chatId: 'customer:[email protected]',
from: '[email protected]',
to: '[email protected]',
content: 'Hello',
type: 'user' | 'ai-assistant' | 'system',
senderRole: 'customer' | 'agent',
sentTime: 1714000000000,
status: 'sent',
files: [...],
data: { /* same fields nested */ },
}
Inactivity timeouts
Once an agent picks up a customer, two Redis keys arm:
chat-response-timeout:{orgId}:{chatId}— 5 min, expects a customer replychat-global-timeout:{orgId}:{chatId}— 30 min hard cap
Warnings fire 1 minute before each timeout. On expiry the server emits a system message, then chat-ended with reason: 'inactivity'. Any new customer message resets both keys.
The widget should send chatSessionId from localStorage on every connect. Without it, every reconnect creates a brand new session and the user's history disappears.