Skip to main content

Server-to-server (Telephony)

The Quickstart connects a browser to an agent. This guide covers the other case: driving an agent from a server, with no browser and no microphone — the shape you need for telephony (a phone call bridged in from Twilio, Telnyx, a SIP trunk) or any backend that already has the audio.

Which transport?​

/launch offers two transports (the transport field). They aren't interchangeable — pick by where the audio lives:

Daily (default)WebSocket
Best forBrowsers, mobile appsServers, telephony
Audio sourceA real device (mic/speaker)Your own PCM stream
TransportWebRTCA single WebSocket, protobuf frames
You get backa room urla wsUrl

Use Daily for browsers. WebRTC handles the things a real device on a real network needs — microphone capture and playback, echo cancellation, jitter and packet-loss recovery, NAT traversal. You get all of that for free, so any app with a human at a device should use it. See the Quickstart.

Use the WebSocket transport on servers. When the audio already lives on your side — a carrier's media stream for a phone call, another service, a file — there's no device and no browser to run WebRTC. The WebSocket transport just carries raw PCM frames over one connection, which is exactly what a telephony bridge needs.

note

Each launch spawns one bot, so a wsUrl is good for a single connection and a single session. Sessions close after 15 minutes of inactivity.

Audio format​

The transport carries 16 kHz mono PCM in and 22.05 kHz mono PCM out. If your audio is at another rate — telephony is 8 kHz µ-law (PCMU) — the media manager below matches it for you, so you can work entirely in your own rate.

Node.js example with client-js​

@pipecat-ai/client-js is written for the browser, but it runs on the server with two small adaptations:

  1. A custom media manager. The managers shipped with the WebSocket transport (WavMediaManager, DailyMediaManager) assume a browser — AudioContext, getUserMedia, MediaStream. Server-side you supply your own. The transport only needs two things from it: a callback to push "microphone" audio into, and a bufferBotAudio() method it calls with the bot's audio.
  2. A window stub. The client probes window?.navigator?.userAgent in its constructor; on a bare window that throws in Node. One line fixes it.

You also need Node ≥ 22, for the global WebSocket the transport uses.

npm install @pipecat-ai/client-js @pipecat-ai/websocket-transport wasm-audio-resampler
Keep the key on the server — you're already there

The browser Quickstart proxies /launch through a backend to hide the API key. Here your code is the backend, so it calls /launch directly with the bearer key. Nothing changes about keeping the key off any client.

A small resampler helper​

soxr.js matches sample rates with Soxr — one instance per direction:

soxr.js
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";

const require = createRequire(import.meta.url);
const { default: SoxrResampler, SoxrDatatype, SoxrQuality } =
require("wasm-audio-resampler");

const WASM_BINARY = readFileSync(
join(dirname(require.resolve("wasm-audio-resampler")), "soxr_wasm.wasm"),
);

export async function createResampler(inputRate, outputRate) {
const r = new SoxrResampler(
1, // channels
inputRate,
outputRate,
SoxrDatatype.SOXR_INT16,
SoxrDatatype.SOXR_INT16,
SoxrQuality.SOXR_QQ,
);
await r.init(undefined, { wasmBinary: WASM_BINARY });
return (int16) => {
const inBytes = new Uint8Array(int16.buffer, int16.byteOffset, int16.byteLength);
const out = Uint8Array.from(r.processChunk(inBytes)); // 2-byte-aligned copy
return new Int16Array(out.buffer, 0, out.length >> 1);
};
}

The media manager​

It works in your own bridgeRate (8 kHz for telephony); matching to the transport's rates happens here, via the helper above.

node-media-manager.js
import { createResampler } from "./soxr.js";

const FRAME_MS = 20;
const PIPELINE_IN_RATE = 16000; // what the agent expects
const PIPELINE_OUT_RATE = 22050; // what the agent sends

export class NodeMediaManager {
constructor({ inputPcm = new Int16Array(0), bridgeRate = 8000, onBotAudio }) {
this._inputPcm = inputPcm; // mono PCM at bridgeRate to send as the "mic"
this._onBotAudio = onBotAudio; // called with bot audio, resampled to bridgeRate
this._bridgeRate = bridgeRate;
this._frameSamples = Math.round((bridgeRate * FRAME_MS) / 1000);
this._offset = 0;
this._timer = null;
this._userAudioCallback = () => {};
this._micEnabled = true;
this._tx = null; // bridgeRate -> 16 kHz
this._rx = null; // 22.05 kHz -> bridgeRate
}

// --- called by the transport ---
setUserAudioCallback(cb) {
this._userAudioCallback = cb;
}
setClientOptions() {}

async initialize() {
this._tx = await createResampler(this._bridgeRate, PIPELINE_IN_RATE);
this._rx = await createResampler(PIPELINE_OUT_RATE, this._bridgeRate);
}

async connect() {
// One 20 ms frame every 20 ms — input first, then silence — upsampled to
// 16 kHz before it reaches the transport. For a live call, pull frames from
// your media stream here instead of from a buffer.
this._timer = setInterval(() => {
const frame = new Int16Array(this._frameSamples);
if (this._offset < this._inputPcm.length) {
frame.set(this._inputPcm.subarray(this._offset, this._offset + this._frameSamples));
this._offset += this._frameSamples;
}
const upsampled = this._tx(frame);
if (upsampled.length) this._userAudioCallback(upsampled.buffer);
}, FRAME_MS);
}

async disconnect() {
if (this._timer) clearInterval(this._timer);
this._timer = null;
}

async userStartedSpeaking() {}

bufferBotAudio(data, _id) {
const pcm = data instanceof Int16Array ? data : new Int16Array(data);
this._onBotAudio?.(this._rx(pcm)); // 22.05 kHz -> bridgeRate
return pcm; // the transport expects the (player-rate) samples back
}

// --- device/UI methods: no hardware off a browser, so all no-ops ---
async getAllMics() { return []; }
async getAllCams() { return []; }
async getAllSpeakers() { return []; }
updateMic() {}
updateCam() {}
updateSpeaker() {}
get selectedMic() { return {}; }
get selectedCam() { return {}; }
get selectedSpeaker() { return {}; }
enableMic(enable) { this._micEnabled = enable; }
enableCam() {}
enableScreenShare() {}
get isCamEnabled() { return false; }
get isMicEnabled() { return this._micEnabled; }
get isSharingScreen() { return false; }
tracks() { return { local: {}, bot: {} }; }
get supportsScreenShare() { return false; }
}

Launch and connect​

index.js
// @pipecat-ai/client-js probes `window?.navigator?.userAgent` in its
// constructor; Node has no `window`, so stub it before constructing the client.
globalThis.window ??= {};

import { PipecatClient } from "@pipecat-ai/client-js";
import {
ProtobufFrameSerializer,
WebSocketTransport,
} from "@pipecat-ai/websocket-transport";
import { NodeMediaManager } from "./node-media-manager.js";

const API = "https://agents.api.respeecher.com";
const KEY = process.env.RESPEECHER_AGENTS_API_KEY; // stays on the server
const BRIDGE_RATE = 8000; // your audio's rate, in and out (e.g. telephony)

// 1. Launch a WebSocket session. This is the server-side call that holds the
// key — no proxy needed, because this whole program is your backend.
const res = await fetch(`${API}/launch`, {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
system_prompt: "You are a friendly assistant. Keep replies short.",
voice_id: "marta",
transport: "websocket",
}),
});
const { wsUrl } = await res.json();

// 2. Wire up audio at your own rate (BRIDGE_RATE). The manager resamples to the
// agent's 16 kHz / 22.05 kHz with Soxr, so nothing else has to. For a live
// call, feed frames from your carrier and write onBotAudio back to it.
const mediaManager = new NodeMediaManager({
inputPcm: myInputPcm, // mono PCM at BRIDGE_RATE
bridgeRate: BRIDGE_RATE,
onBotAudio: (pcm) => mySink.write(pcm), // mono PCM at BRIDGE_RATE
});

const client = new PipecatClient({
transport: new WebSocketTransport({
serializer: new ProtobufFrameSerializer(),
mediaManager,
recorderSampleRate: 16000, // the agent's fixed input rate
playerSampleRate: 22050, // the agent's fixed output rate
}),
enableMic: true,
callbacks: {
onBotReady: () => console.log("bot ready"),
onBotTranscript: (d) => console.log("bot:", d.text),
onError: (m) => console.error("error:", m),
},
});

// 3. Connect to the wsUrl you were handed.
await client.connect({ wsUrl });

Everything else works exactly as in the browser — the client is the same. Tool calls, events, and sendText behave identically; see Quickstart §3–5. For example, register a tool handler before connecting:

client.registerFunctionCallHandler("get_time", async () => ({
time: new Date().toLocaleTimeString(),
}));

Bridging a phone call​

For telephony the only extra step is the µ-law ⇄ PCM codec — the media manager already works at 8 kHz:

  • Inbound (caller → agent): µ-law-decode each frame to PCM and feed it to the manager. In connect(), pull from your media stream instead of the buffer.
  • Outbound (agent → caller): onBotAudio hands you 8 kHz PCM — µ-law-encode it and write it back to the call.