Skip to content

Repository files navigation

@3cx/call-control-sdk

SDK for integrating AI providers with 3CX PBX via the Call Control API.

Installation

npm install @3cx/call-control-sdk

Quick Start

import { CallControlClient } from "@3cx/call-control-sdk";

const client = new CallControlClient({
  pbxBase: "https://your-pbx.example.com",
  appId: "your-app-id",
  appSecret: "your-app-secret",
});

await client.connect();

client.on("participantConnected", async (participant) => {
  console.log(`Participant ${participant.id} connected`);

  // Get audio stream from the caller (PCM 8kHz 16-bit mono)
  const audioStream = await participant.getAudioStream();

  // Create audio writer to the caller
  const audioWriter = participant.getAudioWriter();

  // Pipe audio through your AI provider (STT -> Agent -> TTS)
  // ...

  // Write PCM 8kHz 16-bit mono audio back
  audioWriter.write(pcmBuffer);

  // Call actions directly on the participant
  await participant.transfer("1001");
});

client.on("participantDisconnected", (participantId) => {
  // SDK automatically cleans up audio streams/writers
  console.log(`Participant ${participantId} disconnected`);
});

Extension Participant Monitoring

When extensions are attached to the app's service principal (controlled DNs), the SDK emits separate events for their call activity. This lets you monitor calls on those extensions without conflating them with calls on the app's own programmable DN.

// Calls on the app's own DN - full control (audio + call actions)
client.on("participantConnected", async (participant) => {
  console.log(`Own DN call from ${participant.info.party_caller_id}`);
  const audio = await participant.getAudioStream(); // works
  // ...
});

// Calls on monitored extensions - call actions only, no audio
client.on("extensionParticipantConnected", async (participant) => {
  console.log(
    `Extension ${participant.dn} call from ${participant.info.party_caller_id}`,
  );
  // participant.getAudioStream() -> throws Error
  await participant.transfer("1001"); // works
});

client.on("extensionParticipantDisconnected", (participantId) => {
  console.log(`Extension call ${participantId} ended`);
});

Why no audio on extension participants?

Audio streams and DTMF are only available for the app's own programmable DN (RoutePoint). Extension calls are physically handled by the extension's own device (IP phone, softphone, etc.) - the media path goes directly between the PBX and that device. The programmable extension never sits in the media path for those calls, so there is no audio to intercept or inject. Call control actions (transfer, drop, answer, divert) work because they operate at the signaling level, not the media level.

Event lifecycle note

A RoutePoint (the app's own DN) answers incoming calls automatically - participants almost always arrive with status Connected, so participantConnected fires immediately.

Extensions behave differently: a call first rings on the device (Ringing), and only becomes Connected after the user picks up. The extensionParticipantConnected event fires only when the extension actually answers the call. To detect incoming (ringing) calls on extensions, listen to extensionParticipantUpdated and check participant.info.status:

client.on("extensionParticipantUpdated", (participant) => {
  if (participant.info.status === "Ringing") {
    console.log(`Extension ${participant.dn} is ringing`);
  }
});
Capability Own DN (RoutePoint) Extension DNs
Call events participantConnected / Updated / Disconnected extensionParticipantConnected / Updated / Disconnected
Audio streams Yes No
DTMF Yes No
Transfer / Drop / Divert Yes Yes
Answer Yes Only if the device reports direct_control (uaCSTA)
Attach data Yes Yes

divert and routeTo redirect a call that has not been answered yet, so they need a leg in Ringing state (an inbound call). On an established call, or on the leg that placed the call (Dialing), the PBX rejects them with HTTP 422 - use transfer there.

Outbound Calls

makeCall returns the created participant ID directly from the API response, so you don't have to wait for a WebSocket event to correlate the call:

const participantId = await client.makeCall("101");
// or from a specific source DN:
const participantId = await client.makeCall("101", "cctest2");

if (participantId !== undefined) {
  await client.attachPartyData(participantId, { public_ticket: "1234" });
}

The PBX may return 202 Accepted (call accepted, id not yet available). In that case makeCall resolves to undefined - track the call via WebSocket events instead.

Attached Data

Attach metadata to a call with attachParticipantData / attachPartyData. Every key must be prefixed with public_ - the PBX rejects other keys with HTTP 422.

Both methods take the same participant ID and write two different fields on that participant's record (the same call leg). They do not target the collocutor's DN or a second participant object:

SDK API / accessor PBX field on this participant Meaning
attachParticipantData / participantData participant_attached_data Data attached to this leg
attachPartyData / partyData caller_attached_data Data attached under the party/caller slot of this leg
// via the client (same participantId for both)
await client.attachParticipantData(participantId, { public_ticket: "1234" });
await client.attachPartyData(participantId, { public_peer: "181" });

// or via a participant handle
await participant.attachParticipantData({ public_ticket: "1234" });
await participant.attachPartyData({ public_peer: "181" });

console.log(participant.participantData.public_ticket); // '1234'
console.log(participant.partyData.public_peer); // '181'

To inspect another DN's legs, use client.getState().callcontrol.get(dn)?.participants (or the live handle from getExtensionParticipantHandle). There is no getParticipantByDn helper.

MCP Integration

Use the SDK's token store with 3CX MCP server:

const authProvider = client.createMcpAuthProvider();
const mcpUrl = client.getMcpUrl();

const mcpServer = new MCPServerStreamableHttp({
  url: mcpUrl,
  authProvider,
});

API Reference

Participant

Method Description
id Numeric participant ID
dn DN number this participant belongs to
isExtensionParticipant true if this is a monitored extension participant
info Raw 3CX CallParticipant metadata
destroyed Whether participant has been cleaned up
supportsDirectControl PBX direct_control (uaCSTA): device can be controlled remotely, so answer() is allowed
participantData This leg's participant_attached_data via attachParticipantData() ({} if none)
partyData This leg's caller_attached_data via attachPartyData() ({} if none)
getAudioStream() Get readable PCM audio stream (own DN only)
getAudioWriter() Get writable audio channel (own DN only)
makeCall(to) Place a new independent outbound call from this participant's DN
transfer(destination, reason?) Transfer to destination
drop() Drop from call
answer() Answer incoming call. RoutePoint legs only, or extensions with supportsDirectControl
routeTo(destination, timeout?, reason?) Route to destination
divert(destination, reason?) Divert call
transferToVoiceMail(dn, reason?) Transfer to voicemail
routeToVoiceMail(dn, timeout?, reason?) Route to voicemail
divertToVoiceMail(dn, reason?) Divert to voicemail
attachParticipantData(data) Write participant_attached_data on this leg
attachPartyData(data) Write caller_attached_data on this leg (not the remote DN)
cancelStreamQueue() Cancel PBX-queued outgoing audio (ends active POST; prefer getAudioWriter().clear() for barge-in)

CallControlClient

Method Description
connect() Connect to 3CX PBX (auth + WebSocket)
disconnect() Disconnect and clean up resources
getParticipantHandle(id) Look up active own-DN Participant handle by ID
getExtensionParticipantHandle(id) Look up active extension Participant handle by ID (use this for monitored DNs)
getAudioStream(participantId) Get readable PCM audio stream
createAudioWriter(participantId) Create writable audio channel with keep-alive
transfer(participantId, destination) Transfer participant to extension
drop(participantId) Drop participant from call
answer(participantId) Answer incoming call. The PBX accepts this only on RoutePoint legs or extensions whose device reports direct_control (uaCSTA); otherwise HTTP 422
routeTo(participantId, destination) Route participant
divert(participantId, destination) Divert call
makeCall(to, from?) Place outbound call. Returns the created participant ID (HTTP 200) or undefined (HTTP 202). from defaults to appId
attachParticipantData(participantId, data) Write participant_attached_data on that participant's leg
attachPartyData(participantId, data) Write caller_attached_data on that participant's leg (same ID; not the remote DN)
cancelStreamQueue(participantId) Cancel PBX-queued outgoing audio (ends active POST; prefer createAudioWriter().clear() for barge-in)
controlParticipant(participantId, method, body?) Generic call control action
getParticipant(id) Raw CallParticipant from state for the app's own DN only. Extension legs: use getExtensionParticipantHandle(id) or getState().callcontrol.get(dn)?.participants
getState() Get full call control state (all visible DNs and their participants)
getFullInfo() Fetch full state from REST API
createMcpAuthProvider() Create MCP auth provider
getMcpUrl() Get MCP endpoint URL

Events - RoutePoint (Own DN)

Fired for participants on the app's own programmable DN. Full control: audio streams, DTMF, and call actions.

Event Payload Description
participantConnected Participant Participant connected to call
participantDisconnected number Participant removed from call
participantUpdated Participant Participant state changed
dtmf string, number DTMF digits received and Participant who pressed

Events - Monitored Extensions

Fired for participants on extensions attached to the service principal. Call control only - no audio or DTMF (the media path is handled by the extension's own device, not by the programmable extension).

Event Payload Description
extensionParticipantConnected Participant Extension participant connected
extensionParticipantDisconnected number Extension participant removed
extensionParticipantUpdated Participant Extension participant state changed

Events - Connection

Event Payload Description
connected - WebSocket connected
disconnected - WebSocket disconnected
error Error Error occurred

License

MIT

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages