Skip to content

With audio: false, AgentCall leaves the microphone and speakers alone, and you bring the audio. Use it to call your agent from Node.js: automated voice tests, a phone line, a kiosk with its own audio stack, or recording calls.

import { AgentCall } from "@voice213/agent-client";

const call = new AgentCall({ agentId: "k3x9m2p7q4wz", audio: false });

call.on("audio", (pcm24k) => speaker.write(pcm24k));      // the agent's voice
call.on("interrupted", () => speaker.flush());           // the caller cut in: drop what's queued
await call.start();
microphone.on("data", (pcm16k) => call.sendAudio(pcm16k)); // the caller's voice

​The audio format

DirectionFormat
Caller → agent (sendAudio)16-bit signed little-endian PCM, mono, 16 kHz, as an Int16Array or ArrayBuffer
Agent → caller (audio event)16-bit signed little-endian PCM, mono, 24 kHz, as an Int16Array
  • Send in small chunks, in real time. 20 to 40 ms per sendAudio (320 to 640 samples), at the pace the audio was recorded.
  • Keep sending during silence. The agent knows the caller has finished a sentence from the silence after it. If you stop sending, it waits. While the call is muted (setMuted(true)), keep calling sendAudio: the SDK replaces what you send with silence of the same length.
  • Play the agent's voice as it comes. It usually arrives faster than real time, so queue it. Empty your queue on interrupted: the caller spoke over the agent, and the rest of its sentence must not play.

INPUT_SAMPLE_RATE (16000) and OUTPUT_SAMPLE_RATE (24000) are exported too.

​Node.js

Node.js 22 and later have the fetch and WebSocket the SDK needs. On Node.js 20, pass the ws package:

import WebSocket from "ws";

const call = new AgentCall({ agentId: "k3x9m2p7q4wz", audio: false, webSocket: WebSocket });

A server calls your agent the way a visitor does, through its public endpoint, so the same rules apply. Servers send no Origin header, so the agent's Allowed websites list must be empty. And each IP address can start 20 calls an hour. That's fine for tests and pilots. For production phone traffic, use WhatsApp calls, or contact us.

​Test a voice flow from a recording

Play a recorded question to your agent, record its answer, and check the transcript. This makes a regression test you can run after every change to the instructions:

import { readFile, writeFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { AgentCall, resamplePcm16 } from "@voice213/agent-client";

/** A 16-bit mono WAV file's samples and rate. */
function readWav(file) {
  let rate = 16000;
  let data = null;
  for (let at = 12; at + 8 <= file.length; ) {
    const id = file.toString("ascii", at, at + 4);
    const size = file.readUInt32LE(at + 4);
    if (id === "fmt ") rate = file.readUInt32LE(at + 12);
    if (id === "data") data = file.subarray(at + 8, at + 8 + size);
    at += 8 + size + (size % 2);
  }
  return { rate, pcm: new Int16Array(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength)) };
}

/** 16-bit mono PCM as a WAV file. */
function wavFile(pcm, rate) {
  const header = Buffer.alloc(44);
  header.write("RIFF", 0);
  header.writeUInt32LE(36 + pcm.byteLength, 4);
  header.write("WAVEfmt ", 8);
  header.writeUInt32LE(16, 16);
  header.writeUInt16LE(1, 20); // PCM
  header.writeUInt16LE(1, 22); // mono
  header.writeUInt32LE(rate, 24);
  header.writeUInt32LE(rate * 2, 28);
  header.writeUInt16LE(2, 32);
  header.writeUInt16LE(16, 34);
  header.write("data", 36);
  header.writeUInt32LE(pcm.byteLength, 40);
  return Buffer.concat([header, Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength)]);
}

const question = readWav(await readFile("question.wav"));
const pcm = resamplePcm16(question.pcm, question.rate, 16000);
const heard = [];

const call = new AgentCall({ agentId: process.env.VOICE213_AGENT_ID, audio: false });
call.on("audio", (chunk) => heard.push(chunk));
call.on("message", ({ role, text }) => console.log(`${role}: ${text}`));

/** Resolves with the agent's next completed sentence. */
const agentSays = () =>
  new Promise((resolve) => {
    const stop = call.on("message", (message) => {
      if (message.role !== "agent") return;
      stop();
      resolve(message.text);
    });
  });

const greeting = agentSays();
await call.start();
await greeting; // its opening line (leave this out if your agent waits for the caller)

// The question, in real time, 40 ms at a time, then two seconds of silence to end the turn
const answer = agentSays();
const frame = 640;
for (let at = 0; at < pcm.length + 2 * 16000; at += frame) {
  call.sendAudio(at < pcm.length ? pcm.subarray(at, at + frame) : new Int16Array(frame));
  await sleep(40);
}

console.log("The agent answered:", await answer);
await sleep(3000); // let its voice finish arriving
const ended = await call.end();

const voice = new Int16Array(heard.reduce((n, c) => n + c.length, 0));
heard.reduce((at, c) => (voice.set(c, at), at + c.length), 0);
await writeFile("answer.wav", wavFile(voice, 24000));
console.log(ended, call.transcript.map((m) => `${m.role}: ${m.text}`));

Run it with VOICE213_AGENT_ID=k3x9m2p7q4wz node ask.mjs. The call is a real conversation: it appears in your dashboard and counts against your call minutes.

To type instead of speak, skip the audio and use call.sendText("…"). The agent answers by voice, and you get the transcript and audio the same way.

​A phone line (Twilio)

A phone network carries 8 kHz μ-law audio. The SDK converts it both ways: decodeMulaw and encodeMulaw for the codec, and createResampler for the rates. This bridge connects a Twilio call to your agent through Twilio's bidirectional Media Streams. The bridge's address is public, and every call it starts is billed to you, so the TwiML hands it a key of your own and the bridge refuses any stream without it:

<Response>
  <Connect>
    <Stream url="wss://bridge.example.com/twilio">
      <Parameter name="bridgeKey" value="YOUR_BRIDGE_KEY" />
    </Stream>
  </Connect>
</Response>
import crypto from "node:crypto";
import { WebSocketServer } from "ws";
import { AgentCall, createResampler, decodeMulaw, encodeMulaw } from "@voice213/agent-client";

// The same random value as in the TwiML (openssl rand -hex 24)
const BRIDGE_KEY = process.env.BRIDGE_KEY;
if (!BRIDGE_KEY) throw new Error("Set BRIDGE_KEY");
const sameKey = (given = "") =>
  given.length === BRIDGE_KEY.length && crypto.timingSafeEqual(Buffer.from(given), Buffer.from(BRIDGE_KEY));

const server = new WebSocketServer({ port: 8080, path: "/twilio", maxPayload: 64 * 1024 });

server.on("connection", (twilio) => {
  let streamSid = null;
  // One resampler per direction, kept for the whole call so chunks join seamlessly
  const toAgent = createResampler(8000, 16000);
  const toPhone = createResampler(24000, 8000);
  const call = new AgentCall({ agentId: process.env.VOICE213_AGENT_ID, audio: false });

  call.on("audio", (pcm24k) => {
    const payload = Buffer.from(encodeMulaw(toPhone.process(pcm24k))).toString("base64");
    twilio.send(JSON.stringify({ event: "media", streamSid, media: { payload } }));
  });
  // The caller spoke over the agent: drop the audio Twilio still holds
  call.on("interrupted", () => twilio.send(JSON.stringify({ event: "clear", streamSid })));
  call.on("ended", () => twilio.close());
  call.on("error", (error) => {
    console.error("Agent call failed:", error.code, error.message);
    twilio.close();
  });

  twilio.on("message", (raw) => {
    let message;
    try {
      message = JSON.parse(raw);
    } catch {
      return twilio.close();
    }
    if (message.event === "start") {
      // No key, no call: anyone can reach this address
      if (!sameKey(message.start.customParameters?.bridgeKey)) return twilio.close();
      streamSid = message.start.streamSid;
      call.start();
    } else if (message.event === "media" && call.status === "connected") {
      call.sendAudio(toAgent.process(decodeMulaw(Buffer.from(message.media.payload, "base64"))));
    } else if (message.event === "stop") {
      call.end();
    }
  });
  twilio.on("close", () => call.end());
});

Twilio sends 20 ms of audio per message, which is the pace the agent expects. Mind the limits in the warning above before you put real phone traffic on it.

​Record calls in the browser

The audio event fires with browser audio too. It's a copy, and playback isn't affected. Collect the agent's side of a call:

const chunks = [];
call.on("audio", (pcm) => chunks.push(pcm));
call.on("ended", () => uploadRecording(chunks)); // 24 kHz 16-bit mono PCM

To record the caller too, use the MediaRecorder API on your own microphone stream. The SDK opens its own and doesn't share it.

​Helpers

ExportWhat it does
createResampler(fromRate, toRate)A streaming resampler: .process(Int16Array) for each chunk. When it lowers the rate, it filters out what the new rate can't carry.
resamplePcm16(pcm, fromRate, toRate)Resamples a whole recording at once
encodeMulaw(pcm), decodeMulaw(bytes)G.711 μ-law, the telephone codec
floatToPcm16(float32), pcm16ToFloat(int16)Between Web Audio's float samples and 16-bit PCM

Was this page helpful?