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.

WS_SECRET handshake Rooms: dashboard / ext:{n} src/ws/events.js

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

RoomJoin asReceives
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.

EmitPayloadServer does
join{ role: 'dashboard' } or { role: 'agent', extension: '1007' }Join room, then emit joined plus initial data
leaveLeave room. Agent: decrement panel count
readyPresence ready. Writes hold_dialer_employee.panel_status = active
pausePresence paused. Writes hold_dialer_employee.panel_status = pause
presence{ presence: 'ready' | 'paused' }Same as ready / pause
pingReply 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.

ListenRoomWhen
joinedThat socketAfter a successful join
snapshotdashboardDashboard join, and full AMI snapshot
patchdashboardAMI incremental device-state changes
statusext:{n}AMI + panel/presence for that extension
paneldashboardAgent join, leave, ready, pause
presencedashboardLast tab for an extension left (reason: 'left')
dialerBothEngine phase: claim, buffer, connect, hangup
call_connectedBothBoth parties talking (once per call)
inboundNot emitted. PSTN inbound is off
healthdashboardAMI/DB health
pongThat socketReply to ping
server_errorThat socketJoin / 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

PageEmitsListens
Dashboard /join dashboardsnapshot, patch, dialer, panel, presence, health
Agent /agent.html?ext=1007join agent, ready / pausestatus, 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.