Chat is the exception to the rule in Overview. A WebSocket carries a long-lived, bidirectional stream; proxying it per-message through your own server buys none of the safety a request proxy buys, and costs a hop on every keystroke. The chat client connects to AppEngine directly and authenticates over the socket.
The reference implementation is the chat-client project — a React widget embedded into a host page.
Connecting
Socket.IO, on the /chat namespace:
import io from 'socket.io-client';
const socket = io(`${appengineHost}/chat`, {
transports: ['websocket'],
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 1000,
reconnectionDelayMax: 10000,
auth: {
token,
orgId,
configId,
deviceId: getDeviceId(),
context: {
currentUrl: window.location.href,
device: /mobile/i.test(navigator.userAgent) ? 'mobile' : 'desktop',
language: navigator.language,
deviceId: getDeviceId(),
chatSessionId: existingChatSession,
},
},
});transports: ['websocket'] skips the HTTP long-polling handshake. Credentials go in auth, which Socket.IO sends on the connection handshake — not in the URL, where they would end up in proxy logs and browser history.
chatSessionId is persisted locally and replayed on reconnect so a visitor who refreshes rejoins the same conversation rather than starting a new one.
Authenticating
Connection and authentication are separate. connect means the socket opened; the server then emits authenticate with the verdict:
socket.on('connect', () => {
// transport is up — not yet authorised
});
socket.on('authenticate', (res) => {
if (res?.success) {
onAuthenticated(socket, res); // res.chatSessionId
} else {
socket.close();
clearSession();
}
});Do not send messages on connect. Wait for a successful authenticate — anything emitted before it is unauthenticated and will be rejected.
Event vocabulary
Session and transport
| Event | Meaning |
|---|---|
authenticate | Auth verdict; carries chatSessionId |
connect_error, error | Transport or server error |
token_expired | Credential expired — re-authenticate |
session-expiring, session-expired | The conversation is ageing out |
disconnect | Socket closed; reconnection is automatic |
Conversation
| Event | Meaning |
|---|---|
message | A complete message |
chat-stream | An incremental token from an AI reply |
status, update | Typing and state changes |
Routing to a human
| Event | Meaning |
|---|---|
queued, queue-update | Waiting for an agent, and position |
agent-assigned, agent-changed | An agent picked the conversation up |
chat-transferred | Handed to another agent |
chat-ended | Conversation closed |
presence-change | Agent availability changed |
Proactive engagement and broadcast
| Event | Meaning |
|---|---|
engage | Server-initiated outreach |
broadcast-started, broadcast-ended | A live broadcast |
webrtc-offer, ice-candidate | WebRTC negotiation for audio/video |
Client to server: authenticate, close-chat, broadcast-join.
Streaming replies
AI answers arrive token by token on chat-stream and must be accumulated, with message as the settled version:
socket.on('chat-stream', (chunk) => appendToDraft(chunk));
socket.on('message', (msg) => commitMessage(msg));Reconnection
Reconnection is automatic and bounded — ten attempts with a growing delay, capped at ten seconds. Two things are your responsibility:
- Identity is re-sent on every reconnect. The
authpayload is evaluated again, so a token that expired mid-session fails on reconnect; handletoken_expiredby refreshing before the next attempt. - Attempts are finite. After ten failures the socket stops trying. Surface that to the visitor rather than leaving a dead widget that looks live.
Embedding
The widget mounts into a host page and keeps its own state, so it does not require the host to be React. Point it at your AppEngine host and pass orgId and configId — configId selects which chat configuration (routing rules, AI persona, availability) the conversation runs under.