Authentication and presence Live
The /agent namespace requires a workspace JWT in auth.token. The /widget namespace accepts an anonymous handshake. Both contribute to the presence model: who is online, who is available to take work, and which conversations are being read in real time.
JWT in auth.token
Pass the workspace JWT under auth.token when opening the socket. The token is the same one returned by POST /api/v1/auth/login and used for REST calls. The server verifies the signature, expiry, and tokenVersion match.
import { io } from "socket.io-client";
const socket = io("wss://app.message.com/agent", {
auth: {
token: workspaceJwt // from POST /api/v1/auth/login
},
transports: ["websocket"]
});
socket.on("connect_error", (err) => {
if (err.message === "unauthorized") {
// JWT expired or revoked; re-login
refreshAuthAndReconnect();
}
});Do not pass the JWT in a query parameter. Query strings end up in proxy logs and browser histories. Socket.io auth is transmitted in the handshake body and is the right place for credentials.
Token lifecycle
- JWTs expire after 7 days.
- Logging out of the dashboard (
POST /api/v1/auth/logout) bumpstokenVersion, invalidating every live token for that agent. - Re-issuing a token mid-session is supported. Set
socket.auth = { token: newJwt }and callsocket.connect().
async function refreshAuthAndReconnect() {
const { token } = await fetch("/api/auth/refresh", { method: "POST" }).then(r => r.json());
socket.auth = { token };
socket.connect();
}Connect errors
| Error | Cause | Action |
|---|---|---|
unauthorized | Missing, malformed, expired, or version-bumped token. | Refresh auth, retry. |
workspace_suspended | Workspace billing or compliance suspension. | Surface in-product warning. |
too_many_connections | Per-agent connection cap exceeded (default 5 simultaneous). | Close stale sockets. |
Presence model
Presence is computed from socket state plus the explicit agent:status flag. The status flag has three values:
available, connected, willing to take new work. Counts toward routing eligibility.away, connected, not taking new work (e.g., on a break). Routing skips.offline, explicitly off. Treated the same as no socket connected.
// Agent sets status on connect (default is 'available')
socket.emit("agent:status", { status: "available" });
socket.emit("agent:status", { status: "away" });
socket.emit("agent:status", { status: "offline" });
// Receive status updates for the team
socket.on("agent:online", ({ agentId, status }) => { /* ... */ });
socket.on("agent:offline", ({ agentId }) => { /* ... */ });If the socket drops without setting offline, the agent stays in their last status for a 60-second grace period (to ride out brief network blips). After the grace window, the server flips them to offline.
Visitor presence
On the widget namespace, presence is socket-driven. A visitor is “active” while at least one of their sessions has an open socket. The agent dashboard receives visitor:online and visitor:offline events tagged with the visitorId.
// Widget side: receive presence of the assigned agent
socket.on("agent:typing", ({ agentName }) => showTypingChip(agentName));
socket.on("agent:typing-stopped", () => clearTypingChip());Multi-tab and multi-device
One agent can connect from multiple tabs, devices, or apps simultaneously. All sockets join the same agent-scoped rooms. Status updates from any socket overwrite the agent's server-side status. Emits are de-duplicated by message ID to keep multi-tab sending from creating duplicates.
How status feeds routing
Routing rules read agent:status at the moment of dispatch:
- New chats and tickets land first with agents whose status is
available, within the conversation's channel and department scope. - Phone routing only rings agents whose status is
availableand whose channels includecalls. - If no agent matches, the conversation queues until one becomes available.
For role and department scoping see the RBAC notes in Agent concept and Department vs team.
Missed-event replay
On reconnect, the server replays events the agent missed during a disconnect window of up to 5 minutes. Older gaps require a REST refetch of the affected conversations. The replay is best-effort and tagged with a replayed: true field so client UIs can suppress notification sounds.
Common pitfalls
- Refreshing token without reconnect. Setting
socket.authalone does not push the new token. Callsocket.connect()(or wait for the next reconnect attempt) for it to take effect. - Treating
awayasoffline. They are different. Agents inawaystill receive events for conversations they already own; they just do not get new routing. - Trusting browser tab visibility. The status flag is server-controlled. Setting
document.hiddenin the browser does not change presence; emitagent:statusexplicitly if you want away-on-tab-hide behaviour. - Not handling
workspace_suspended. A billing failure manifests as a connect rejection here. Surface a clear in-app warning rather than retrying silently.