Skip to main content

Build Your Streaming Server

This page explains what your own WebSocket server receives from the TeleCMI platform and gives you working example code you can copy and run. To switch streaming on and choose the audio options, see the AI Streaming API.

When a call starts, the TeleCMI platform opens a WebSocket connection to your ws_url. Everything you need to identify the call arrives as connection headers on that first handshake request, and the call audio then arrives as binary frames.

Connection Headers

Read these from the HTTP upgrade request when the connection opens. Header names arrive in lower case.

HeaderExampleDescription
call_ida1b2c3d4-5e6f-7890-abcd-ef1234567890Unique ID of the call. Use this to name your files or to match your records.
directioninboundinbound for an incoming call, outbound for an outgoing call.
from919876543210The caller number.
to918000000000The virtual number that was dialled.
agent_id1001The agent on the call. Empty when no agent is connected yet.
app_id2221121Your application number.
time1723627800000Platform call timestamp.
mixstereoChannel layout of the audio. See the table below. Read this before parsing the audio.
sample_rate16000Sample rate of the audio in Hz, 8000 or 16000.
custom{"crm_id":"CRM-99871","team":"sales"}The custom_variables you configured, delivered as JSON.
Note
Always read the mix and sample_rate headers instead of hard coding the values. If you change the settings later through the API, your server keeps working without a code change.

Audio Format

The audio arrives as binary WebSocket frames containing raw PCM signed 16 bit little endian samples. There is no WAV header and no JSON wrapper, so you can write the bytes straight to a file or feed them to a speech engine.

How you read each frame depends on the mix header.

mix valueChannelsBytes per frameHow to read it
mono12A single voice, either the caller or the callee.
mixed12Both voices already mixed together into one channel.
stereo24Two interleaved channels, the caller on the left and the callee on the right. Split them apart before saving.
Important
A binary frame is not guaranteed to end exactly on a sample boundary. Keep the leftover bytes of an incomplete sample in a buffer and add them to the front of the next frame, otherwise the channels swap over and the audio turns into noise. The example below shows this handling.

Your server can also receive text frames. The first text frame carries the call UUID as metadata. You can ignore text frames if you only need the audio.

Example 1: Minimal WebSocket Server

This is the smallest server that accepts a stream and saves the raw audio. Install the dependency with npm install ws.

import { WebSocketServer } from 'ws';
import fs from 'fs';

const wss = new WebSocketServer({ port: 8090 });

wss.on('connection', (ws, req) => {
const callId = req.headers['call_id'];
const mix = req.headers['mix'] || 'mono';
const sampleRate = parseInt(req.headers['sample_rate'] || '8000', 10);

console.log(`call ${callId} started: ${req.headers['from']} -> ${req.headers['to']}`);
console.log(`audio: mix=${mix} rate=${sampleRate}`);

const file = fs.createWriteStream(`${callId}.raw`);

ws.on('message', (data, isBinary) => {
if (isBinary) file.write(data);
});

ws.on('close', () => {
file.end();
console.log(`call ${callId} ended`);
});
});

console.log('listening on ws://0.0.0.0:8090');

Example 2: Full Server with WAV Recording

This server reads every header, splits a stereo stream into a separate caller file and callee file, and writes valid WAV files you can play in any audio player.

// stream_server.mjs — TeleCMI streaming receiver
// npm install ws
import { WebSocketServer } from 'ws';
import fs from 'fs';
import path from 'path';

const DUMP_DIR = path.resolve('recordings');
const wss = new WebSocketServer({ port: 8090 });

// Writes raw PCM s16le into a valid mono WAV file
class WavSink {
constructor(filePath, sampleRate) {
this.filePath = filePath;
this.sampleRate = sampleRate;
this.bytesWritten = 0;
fs.mkdirSync(path.dirname(filePath), { recursive: true });
this.fd = fs.openSync(filePath, 'w');
fs.writeSync(this.fd, Buffer.alloc(44)); // reserve space for the header
}

write(buf) {
fs.writeSync(this.fd, buf);
this.bytesWritten += buf.length;
}

close() {
const h = Buffer.alloc(44);
h.write('RIFF', 0); h.writeUInt32LE(36 + this.bytesWritten, 4); h.write('WAVE', 8);
h.write('fmt ', 12); h.writeUInt32LE(16, 16); h.writeUInt16LE(1, 20);
h.writeUInt16LE(1, 22); h.writeUInt32LE(this.sampleRate, 24);
h.writeUInt32LE(this.sampleRate * 2, 28); h.writeUInt16LE(2, 32); h.writeUInt16LE(16, 34);
h.write('data', 36); h.writeUInt32LE(this.bytesWritten, 40);
fs.writeSync(this.fd, h, 0, 44, 0); // rewrite the header with the real sizes
fs.closeSync(this.fd);
console.log(`[wav] wrote ${this.filePath} (${this.bytesWritten} bytes)`);
}
}

// Stereo arrives interleaved: left = caller, right = callee
function splitStereo(buf) {
const frames = buf.length >> 2;
const caller = Buffer.alloc(frames * 2);
const callee = Buffer.alloc(frames * 2);
for (let i = 0; i < frames; i++) {
buf.copy(caller, i * 2, i * 4, i * 4 + 2);
buf.copy(callee, i * 2, i * 4 + 2, i * 4 + 4);
}
return { caller, callee };
}

function parseCallHeaders(req) {
const h = req.headers;

const call = {
from: h['from'] || '',
to: h['to'] || '',
agentId: h['agent_id'] || '',
callId: h['call_id'] || `nocallid_${Date.now()}`,
time: h['time'] || '',
direction: h['direction'] || 'unknown',
appId: h['app_id'] || '',
mix: h['mix'] || 'mono',
sampleRate: parseInt(h['sample_rate'] || '8000', 10)
};

if (!h['mix']) console.warn(`[${call.callId}] no 'mix' header, assuming mono`);
if (!h['sample_rate']) console.warn(`[${call.callId}] no 'sample_rate' header, assuming 8000`);

call.custom = parseCustom(h['custom'], call.callId);
return call;
}

// Accepts plain JSON and base64 encoded JSON, so it keeps working either way
function parseCustom(raw, callId) {
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (e) {
try {
return JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
} catch (e2) {
console.warn(`[${callId}] 'custom' header is not valid JSON:`, raw);
return {};
}
}
}

wss.on('connection', (ws, req) => {
const call = parseCallHeaders(req);

console.log('=== NEW CALL ===');
console.log(`call_id : ${call.callId}`);
console.log(`direction : ${call.direction} from: ${call.from} -> to: ${call.to}`);
console.log(`agent : ${call.agentId} app: ${call.appId} time: ${call.time}`);
console.log(`audio : mix=${call.mix} rate=${call.sampleRate}`);
console.log(`custom :`, call.custom);

// Files are created on the first audio frame, so stray connections make no files
let sinks = null;
const align = call.mix === 'stereo' ? 4 : 2;
let remainder = Buffer.alloc(0);
let frameCount = 0;

ws.on('message', (data, isBinary) => {
if (!isBinary) {
console.log(`[${call.callId}] TEXT frame:`, data.toString());
return;
}

if (!sinks) {
sinks = call.mix === 'stereo'
? {
caller: new WavSink(path.join(DUMP_DIR, `${call.callId}_caller.wav`), call.sampleRate),
callee: new WavSink(path.join(DUMP_DIR, `${call.callId}_callee.wav`), call.sampleRate)
}
: { single: new WavSink(path.join(DUMP_DIR, `${call.callId}_${call.mix}.wav`), call.sampleRate) };
}

frameCount++;

// Carry incomplete samples over to the next frame
let buf = remainder.length ? Buffer.concat([remainder, data]) : data;
const usable = buf.length - (buf.length % align);
remainder = buf.subarray(usable);
buf = buf.subarray(0, usable);
if (!buf.length) return;

if (call.mix === 'stereo') {
const { caller, callee } = splitStereo(buf);
sinks.caller.write(caller);
sinks.callee.write(callee);
} else {
sinks.single.write(buf);
}
});

ws.on('close', () => {
console.log(`=== CALL ENDED [${call.callId}] after ${frameCount} audio frames ===`);
if (sinks) Object.values(sinks).forEach(s => s.close());
});

ws.on('error', (e) => console.error(`[${call.callId}] error:`, e.message));
});

console.log('listening on ws://0.0.0.0:8090');

Testing Your Server

While you are developing, your server runs on your own machine and the TeleCMI platform cannot reach it. Use ngrok to expose it, then set the address it gives you as your ws_url.

ngrok tcp 8090