Socket.IO
What the client emits. What the server emits. Who listens.
Agent panel and dashboard share one Socket.IO server on /socket.io/. This is the wire protocol only. Calling is in the Progressive and Predictive guides. Lead ingest is the HTTP API.
Connect
const socket = io('https://YOUR_HOST', {
transports: ['websocket'],
auth: { token: 'YOUR_WS_SECRET' }
});
socket.on('connect', () => {
socket.emit('join', { role: 'agent', extension: '1007' });
});
WS_SECRET is required. Send it as handshake.auth.token, or query token / key. Wrong key → connect_error unauthorized. After connect, emit join or you are in no room.
Rooms
| Room | Join as | Receives |
|---|---|---|
dashboard | { role: 'dashboard' } | Snapshot, patch, every dialer event, health, panel |
ext:{extension} | { role: 'agent', extension } | That agent’s status, dialer, call_connected |
One socket is one role. Join again leaves the previous room. Several tabs on the same extension share the room. Disconnect waits 3 seconds before the agent is treated as gone (refresh-safe).
Client emits — server listens
The browser emits. Hub in src/ws/hub.js listens.
| Emit | Payload | Server does |
|---|---|---|
join | { role: 'dashboard' } or { role: 'agent', extension: '1007' } | Join room, then emit joined plus initial data |
leave | — | Leave room. Agent: decrement panel count |
ready | — | Presence ready. Writes hold_dialer_employee.panel_status = active |
pause | — | Presence paused. Writes hold_dialer_employee.panel_status = pause |
presence | { presence: 'ready' | 'paused' } | Same as ready / pause |
ping | — | Reply pong |
redial | { oli_id, ondemand? } | Re-originate that lead on this extension (skip wrap / queue). Skill must match |
Socket.IO disconnect is treated as leave. Missing extension on agent join → server_error extension is required to join as agent. Extension not in this module’s hold_dialer_employee roster → extension is not assigned to this dialer. Ready/pause before join also emits server_error. redial before join, or without oli_id, returns { ok: false } (and server_error if you did not pass an ack callback). A successful agent join reloads that employee row and syncs the AMI roster.
Server emits — client listens
The hub emits. The browser listens.
| Listen | Room | When |
|---|---|---|
joined | That socket | After a successful join |
snapshot | dashboard | Dashboard join, and full AMI snapshot |
patch | dashboard | AMI incremental device-state changes |
status | ext:{n} | AMI + panel/presence for that extension |
panel | dashboard | Agent join, leave, ready, pause |
presence | dashboard | Last tab for an extension left (reason: 'left') |
dialer | Both | Engine phase: claim, buffer, connect, hangup |
call_connected | Both | Both parties talking (once per call) |
inbound | — | Not emitted. PSTN inbound is off |
health | dashboard | AMI/DB health |
pong | That socket | Reply to ping |
server_error | That socket | Join / presence failures |
Payloads
joined
{ "role": "agent", "extension": "1007", "rooms": ["ext:1007"] }
Dashboard join is { "role": "dashboard", "rooms": ["dashboard"] }.
dialer
Always has extension and phase: idle, paused, buffer, waiting, dialing, on_call. Also buffer_ms / buffer_ends_at (wrap after a connected call only), connect_ms, on_call_at, talk_ms, client_name, oli_id, form_type. Predictive adds outbound (other ringing/hold legs, phase ringing or waiting) and a floor-wide on_hold count of answered customers on MusicOnHold (outbound only).
call_connected
{
"extension": "1007",
"oli_id": "DUMMY-001",
"form_type": "GST",
"priority_id": 6
}
Once per call when the conversation is actually up.
redial
After a drop, the CRM emits redial from an agent socket. The engine skips wrap time and the queue, claims the lead on this extension, and originates again (AMI). No skill check. ondemand: true allows redial from pause.
socket.emit('redial', { oli_id: 'OLI124' }, (result) => {
// { ok: true, oli_id: 'OLI124' }
// { ok: false, error: 'busy'|'not_ready'|'unknown_oli'|'originate_failed', message: '…' }
});
Works on Received, Not Connected, and Abandoned rows still on master. Finally disposed leads are in hold_dialer_disposed (unknown_oli) until CRM pushes the oli_id again. Rejected if the agent is already on another call, SIP is not idle, or (unless ondemand) paused. Then listen for dialer phase: dialing and call_connected.
status / panel
One extension: AMI category, panel active/inactive, presence ready/paused, name from hold_dialer_employee.display_name, assigned_roles from hold_dialer_employee.skills (stored, unused for claim), panel_status, buffer_time, decline_cooldown_seconds, microsip_alert when the panel is up but AMI is unavailable. CRM profile fields such as email/mobile are not on hold_dialer_employee and may be null.
snapshot
updated_at, ami, summary (AMI on_call / idle / unavailable / total plus outbound on_hold), dialer (active hold_dialer_config, including decline_cooldown_seconds; hold_timeout_seconds is not sent), extensions[] (same shape as status, only endpoints present in this module’s hold_dialer_employee roster — not every PJSIP contact).
inbound
Not emitted. The Hold Lead Auto Dialer is outbound-only. PSTN inbound stays on FreePBX. See the Inbound guide.
Built-in pages
| Page | Emits | Listens |
|---|---|---|
Dashboard / | join dashboard | snapshot, patch, dialer, panel, presence, health |
Agent /agent.html?ext=1007 | join agent, ready / pause | status, dialer, call_connected, joined, server_error |
Socket does not push leads. Use POST /api/leads and PUT /api/escalated-tickets. Normal outbound is still engine-driven; redial is the client asking the engine to originate one specific lead now. Hub onAgentState is an in-process callback into the dialer, not a Socket.IO event.