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 for | Browsers, mobile apps | Servers, telephony |
| Audio source | A real device (mic/speaker) | Your own PCM stream |
| Transport | WebRTC | A single WebSocket, protobuf frames |
| You get back | a room url | a 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.
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:
- 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 abufferBotAudio()method it calls with the bot's audio. - A
windowstub. The client probeswindow?.navigator?.userAgentin its constructor; on a barewindowthat 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
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:
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.
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
// @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):
onBotAudiohands you 8 kHz PCM — µ-law-encode it and write it back to the call.