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.
| Header | Example | Description |
|---|---|---|
call_id | a1b2c3d4-5e6f-7890-abcd-ef1234567890 | Unique ID of the call. Use this to name your files or to match your records. |
direction | inbound | inbound for an incoming call, outbound for an outgoing call. |
from | 919876543210 | The caller number. |
to | 918000000000 | The virtual number that was dialled. |
agent_id | 1001 | The agent on the call. Empty when no agent is connected yet. |
app_id | 2221121 | Your application number. |
time | 1723627800000 | Platform call timestamp. |
mix | stereo | Channel layout of the audio. See the table below. Read this before parsing the audio. |
sample_rate | 16000 | Sample 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
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 value | Channels | Bytes per frame | How to read it |
|---|---|---|---|
mono | 1 | 2 | A single voice, either the caller or the callee. |
mixed | 1 | 2 | Both voices already mixed together into one channel. |
stereo | 2 | 4 | Two interleaved channels, the caller on the left and the callee on the right. Split them apart before saving. |
Important
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