Skip to content

@calloop/agent-sdk ​

The agent side of Calloop's agent protocol for browsers and Node: connect, go available, ring, answer, hang up, transfer, and keep calls alive across network drops, page reloads and device switches. The app keeps its own UI, ringtones and routing; the SDK gives it events, state and the remote audio stream.

js
import { connect } from '@calloop/agent-sdk';

const agent = await connect({
  url: 'https://api.calloop.net',
  getToken: () => fetch('/api/calloop-token').then((r) => r.text()), // your backend signs it
});

agent.setAvailability('AVAILABLE');

agent.on('incoming', (call) => {
  ring(call.customer);                       // your UI
  call.on('remoteStream', (stream) => { audioEl.srcObject = stream; });
  call.on('ended', ({ reason }) => stopRinging());
  acceptButton.onclick = () => call.accept();  // asks for the microphone
  declineButton.onclick = () => call.decline();
});

Connecting ​

connect(options) resolves once Calloop has identified the agent (session:ready).

Option
urlCalloop's base URL
getTokenasync () => jwt, called on every (re)connect — agent tokens are short-lived. Or token for a fixed one.
deviceIdStable id for this device (default: one kept in localStorage). Calloop uses it to tell "this device" from "my other device".
webrtc{ RTCPeerConnection, MediaStream } outside a browser (e.g. @roamhq/wrtc in Node)
getUserMediaasync () => MediaStream for the microphone (default: navigator.mediaDevices.getUserMedia)
iosocket.io-client's io (default: the bundled one) — to use the app's own socket.io-client
purpose'session' (default): the agent's main connection. Anything else (e.g. 'decline') is an auxiliary connection: no presence, no redelivered ringing calls, no resync on connect

ICE/TURN servers come from Calloop; the app never configures them.

The SDK reconnects by itself after a network drop (disconnected, then ready). When Calloop itself ends the connection — the token's signing key was revoked — socket.io doesn't retry: the agent emits closed ({ reason }) and sets agent.closedReason. Call agent.close() and connect() again with a token signed by a current key.

The agent ​

agent.setAvailability('AVAILABLE' | 'OFFLINE', { agentId? })ON_CALL is set by Calloop; agentId: a supervisor setting someone else
agent.startOutbound(callId, { stream? })start an outbound call the consumer created (POST /v1/tenants/{t}/calls)
agent.calls, agent.call(id)this agent's calls
agent.availabilitythis agent's availability (AVAILABLE · ON_CALL · OFFLINE), as Calloop reports it
agent.queuesqueue snapshots by queue id (see below)
agent.teamthe tenant's agents' availability, as it changes (see below)
agent.sync()re-read the calls and queue snapshots now (done on every connect)
agent.refreshSession()new ICE/TURN credentials (done automatically before they expire)
agent.readya promise that resolves with the agent once it is first identified
agent.agent, agent.sessionidentity from session:ready
agent.iceServers, agent.deviceIdthe ICE servers Calloop gave this session; this device's id
agent.close()

Queue snapshots and the team's availability come only to supervisors and to agents whose tenant allows the team view (the tenant's team_view setting). Without it, a plain agent gets no queues, and agent.team holds only itself.

on(event, fn) returns a function that removes the listener; once() and off() work as usual, on the agent, a call and a monitor.

Events: incoming (call), elsewhere (call), callState (call, state, previous), callEnded (call, info), availability, team, queue, ready (after each reconnect), sessionRefreshed, disconnected, closed ({ reason }: Calloop ended the connection, see above), error.

A call ​

call.state: ringing → connecting → active → ended; dialing for an outbound call before the customer answers; elsewhere when its media is on another of the agent's devices.

call.accept({ stream? })answer (default microphone)
call.decline()in a queue, it passes to the next agent
call.hangup()
call.mute(bool)
call.transfer({ agentId } | { queueId })
call.switchHere({ stream? })take a call that's active on another device
call.setPrivate(bool)talk privately to the monitoring supervisor
call.customer, call.direction, call.channel, call.callUuid, call.queueId, call.data
call.isRingAlloffered to every member of a RING_ALL queue at once (first accept wins)
call.muted, call.endReasonas set by mute(); why the call ended (null until then)
call.remoteStream, call.localStream, call.pc

Events: state (state, previous), remoteStream (stream), ended ({ reason }), updated, dtmf, customerMedia, networkTerminating, supervisorMode, privateChanged.

ended reasons: hangup, declined, terminated (see terminationReason: the call ended — or, with transferred, this agent's part of it did: the previous agent of a transfer gets it while the call goes on with the next one), withdrawn (the offer went to someone else), answered_elsewhere, media_failed, accept_failed, error, gone. A failed call's terminated also carries failure: { code, title, details? }, what the provider said (a SIP trunk: 404, Not Found, its Reason header). Other details by reason:

reasonAlso in the ended info
terminatedterminationReason, terminatedBy, failure
withdrawnwithdrawnReason: declined · taken · timeout · overflow
answered_elsewhereby (the agent id), agentName
errorcode, message
media_failed, accept_failederror (the message), when there is one

Supervisors ​

An agent whose token has role: 'SUPERVISOR' (agent.isSupervisor) also gets:

agent.boardthe tenant's live calls (Map callId → call view: status, direction, customer, agent, queue, ivr position), rebuilt on every sync. ivr is filled by live IVR events only: after a sync it is missing until the call's next IVR step
agent.monitor(callId, { stream? })listen to a call → a Monitor
agent.monitorsthe calls being monitored (Map callId → Monitor)
agent.transferCall(callId, { agentId } | { queueId })move any call
agent.setAvailability(value, { agentId })set an agent's availability

Board events: board (the whole board, after each sync), boardCall (a call appeared or changed), boardCallEnded (view, { terminationReason, terminatedBy }), counters (the tenant's live counts, pushed at most every 2 s).

agent.board and the board / boardCall / boardCallEnded events are supervisors' only. An agent whose tenant has the team view can page the board with boardCalls(), but the SDK gives it no live board events.

A dashboard on a large tenant narrows and pages the board instead of holding all of it:

agent.subscribeBoard({ channelIds?, queueIds?, agentIds? })only these lines / queues / agents from now on; {} = the whole tenant
agent.unsubscribeBoard()no board on this connection
agent.boardCalls({ …filter, cursor?, limit? }){ calls, nextCursor }, newest first
agent.boardCounters()supervisors: the counters now

agent.board is still rebuilt from sync() (the whole tenant); a narrowed dashboard keeps its own list from boardCalls() and the board events.

A filter lasts for one connection: after a reconnect Calloop subscribes the new connection to the whole tenant again. Re-apply it on ready:

js
agent.on('ready', () => agent.subscribeBoard({ queueIds: [3] }));
js
const monitor = await agent.monitor(callId);            // asks for the microphone
monitor.on('stream', (s) => { callAudio.srcObject = s; });   // the customer and the agent, mixed
monitor.setMode('whisper');                              // 'listen' | 'whisper' | 'barge'
monitor.mute(true);                                      // your microphone
monitor.stop();

A monitor has callId, state (connecting · active · ended), mode, muted and stream.

listen: the supervisor hears the call, nobody hears the supervisor · whisper: the agent hears the supervisor · barge: both do. Calloop mixes, so switching modes is instant. Monitor events: state, stream (the call as you hear it), mode, modeRefused ({ mode, code, message }: Calloop refused a setMode() — e.g. whisper on a direct call, or a mode the tenant doesn't allow — and monitoring goes on in the current mode), agentPrivate (the agent talking privately to you), agentReconnected, ended ({ reason, code?, message? }). Reasons: stopped, call_ended, ended (Calloop ended it), disconnected, failed (Calloop couldn't start it; code/message say why), not_allowed (the tenant doesn't allow monitoring), closed (agent.close()). A reconnect of the supervisor's socket ends monitoring — start it again.

TypeScript ​

Types ship with the package: connect, CalloopAgent, Call, Monitor and the payload shapes.

What it handles ​

  • Resync on every connect (calls:sync): ringing calls, calls on this device (reconnected automatically after a reload), calls on another device.
  • ICE candidates buffered both ways until each side is ready.
  • Media recovery: no media within 15 s or a failed connection → one reconnect. If that fails too, the call ends locally with media_failed and the SDK asks Calloop to end it (system_failed), so Calloop records it FAILED / NETWORK_ERROR.
  • Direct calls (call.isDirect: a personal line's 1:1 call that Calloop relays without a room) reconnect like any other call (the SDK sends its own offer); agent.monitor() listens without the microphone — the stream carries the customer and the agent as two tracks (listen-only).
  • Offers withdrawn (taken, timed out, declined elsewhere, an unaccepted transfer returned to its queue) and redelivered offers.
  • TURN credentials refreshed on the live connection before they expire, for long shifts.

A first test call ​

Sign a test token with your signing key (the agent token), connect as in Connecting, go available, and call your line.

Calloop — the call engine behind your product.