Building a voice AI agent on DialStack with Telnyx
Answer calls to a DialStack number with an assistant you built in Telnyx. A small relay server sits between the two: DialStack streams the call's audio to it over the attach WebSocket, and the relay forwards it to Telnyx's assistant conversation WebSocket. No Telnyx phone number, SIP trunk or Call Control application is involved.
This is the BYO Receptionist pattern with Telnyx as the AI.
1. Set up the assistant in Telnyx
- In the Telnyx Mission Control portal, create an AI Assistant. Give it its instructions, greeting, voice and any tools there; the relay sends none of its own.
- Copy the assistant ID (
assistant-…). - Create an API key (API v2) under API Keys. The relay sends it as
Authorization: Bearer <key>.
2. Set up DialStack
Create a Voice App whose webhook URL points at your relay, and route a number or dial-plan node to it. See Voice Apps and the Routing section of the BYO guide.
3. Write the relay
The relay answers the webhook with an attach action, accepts DialStack's media WebSocket, and pipes audio between that socket and the assistant. Telnyx speaks PCM16, so the relay decodes caller μ-law, and downsamples and re-encodes the assistant's audio:
import http from 'node:http';
import express from 'express';
import WebSocket, { WebSocketServer } from 'ws';
import { mulaw } from 'alawmulaw';
import { DialStack, MediaStream } from '@dialstack/sdk-server';
const dialstack = new DialStack(process.env.DIALSTACK_API_KEY);
const app = express();
// 1. A call reached the Voice App: attach it to our media endpoint.
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const event = DialStack.webhooks.constructEvent(
req.body,
req.header('x-dialstack-signature'),
process.env.VOICE_APP_WEBHOOK_SECRET
);
if (event.event === 'call.received') {
await dialstack.calls.update(
event.call_id,
{ actions: [{ type: 'attach', url: 'wss://relay.example.com/media' }] },
{ dialstackAccount: event.account_id }
);
}
res.status(200).end();
});
// 2. DialStack opens the media WebSocket: bridge it to the Telnyx assistant.
const server = http.createServer(app);
new WebSocketServer({ server, path: '/media' }).on('connection', (ws) => {
const call = new MediaStream(ws);
const assistant = new WebSocket(
`wss://api.telnyx.com/v2/ai/assistants/${process.env.TELNYX_ASSISTANT_ID}/conversation` +
'?input_sample_rate=8000&input_format=pcm16&output_format=pcm16',
{ headers: { Authorization: `Bearer ${process.env.TELNYX_API_KEY}` } }
);
assistant.on('open', () => assistant.send(JSON.stringify({ type: 'session.update' })));
let ratio; // assistant output rate / 8000, from session.created
let queued = Buffer.alloc(0);
// Agent audio goes out in 20 ms frames (160 bytes of μ-law).
const pacer = setInterval(() => {
if (queued.length < 160) return;
call.sendAudio(queued.subarray(0, 160).toString('base64'));
queued = queued.subarray(160);
}, 20);
// Caller audio: μ-law 8 kHz → PCM16 8 kHz, no resampling.
call.on('audio', (frame) => {
if (!ratio || assistant.readyState !== WebSocket.OPEN) return;
const pcm = mulaw.decode(Buffer.from(frame.payload, 'base64'));
const audio = Buffer.from(pcm.buffer, pcm.byteOffset, pcm.byteLength).toString('base64');
assistant.send(JSON.stringify({ type: 'input_audio_buffer.append', audio }));
});
assistant.on('message', (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === 'session.created') {
ratio = msg.session.audio.output.format.rate / 8000; // set by the assistant's voice
} else if (msg.type === 'response.output_audio.delta') {
const bytes = Buffer.from(msg.delta, 'base64');
const pcm = new Int16Array(new Uint8Array(bytes).buffer, 0, bytes.length >> 1); // copy: aligned
// Average each window down to 8 kHz. Integer ratios only (16, 24, 48 kHz).
const out = new Int16Array(Math.floor(pcm.length / ratio));
for (let i = 0; i < out.length; i++) {
let sum = 0;
for (let j = 0; j < ratio; j++) sum += pcm[i * ratio + j];
out[i] = sum / ratio;
}
queued = Buffer.concat([queued, Buffer.from(mulaw.encode(out))]);
} else if (msg.type === 'input_audio_buffer.speech_started') {
queued = Buffer.alloc(0); // the caller barged in: drop what the agent hadn't said yet
}
});
const hangUp = () => {
clearInterval(pacer);
assistant.close();
call.close();
};
call.on('close', hangUp);
assistant.on('close', hangUp);
});
server.listen(8080);
The voice-ai-agent example in the DialStack SDK is the same relay, with error handling, drift-free pacing and logging. Its downsampler also handles non-integer rates such as 22.05 kHz, and chunks that end mid-window. To run it:
git clone https://github.com/dialstack/dialstack-sdk.git
cd dialstack-sdk
npm install && npm run build # the example links the local server package
cd examples/voice-ai-agent
npm install
cp .env.example .env
Set these in .env:
| Variable | Value |
|---|---|
PUBLIC_URL | The public HTTPS URL of the relay (for example an ngrok URL). |
DIALSTACK_API_KEY | Your DialStack secret key. |
VOICE_APP_WEBHOOK_SECRET | The signing secret of the Voice App. |
TELNYX_API_KEY | The Telnyx API v2 key. |
TELNYX_ASSISTANT_ID | The Telnyx assistant ID. |
TRANSFER_TARGET | Optional. Where to transfer the caller; see below. |
Then start it and call the number:
npm run dev -- --provider telnyx
Audio formats
DialStack's media WebSocket carries μ-law at 8 kHz. Telnyx takes and returns PCM16 only, so the relay converts:
- Caller → Telnyx. The relay connects with
input_sample_rate=8000and decodes μ-law to PCM16. No resampling. - Telnyx → caller. The output rate is chosen by the assistant's voice, not by the client, and Telnyx reports it in the
session.createdevent (session.audio.output.format.rate). The relay reads it there and downsamples to 8 kHz before encoding μ-law. Changing the assistant's voice needs no relay change.
Barge-in and hang-up
- When Telnyx detects the caller speaking (
input_audio_buffer.speech_started), the relay drops the assistant audio it has queued but not yet played, so the caller isn't talked over. - When the caller hangs up, the relay closes the Telnyx socket. When Telnyx ends the conversation, the relay closes the DialStack media socket. That ends the
attach, and DialStack runs the next action you chained after it, if any.
The caller's number
DialStack's call.received webhook carries the caller's number (from_number) and the number they dialled (to_number). The relay keeps them from the webhook and sends them to Telnyx as the dynamic variables caller_number and called_number, in the session.update that opens the conversation. Reference them in the assistant's instructions or greeting, for example {{caller_number}}:
assistant.send(
JSON.stringify({
type: 'session.update',
session: {
assistant: {
dynamic_variables: { caller_number: event.from_number, called_number: event.to_number },
},
},
})
);
session.update must be the first frame, before any audio: Telnyx rejects one sent after the session has started.
Transfer to a human
Hand the caller off with DialStack's transfer action.
- On the assistant in Telnyx, add a client-side tool named
transfer_to_human, with a description telling the assistant when to use it (for example, "Transfer the caller to a person when they ask for one"). The relay ignores its arguments. Telnyx requires arequiredarray in the schema, so either give it an empty one through the API ({"type": "object", "properties": {}, "required": []}) or, in the portal, add one optional string parameter such asreason. If the assistant also has a built-in Transfer tool, remove it or tell the assistant to usetransfer_to_humaninstead: Telnyx doesn't carry this call, so its own transfer can't move it. - Set
TRANSFER_TARGETin the relay's.envto an extension, an E.164 number or asip:URI.
When the assistant calls the tool, Telnyx sends the relay a conversation.item.created event whose item is a function_call. The relay sends the call a transfer, which replaces the running attach:
await dialstack.calls.update(
callId,
{ actions: [{ type: 'transfer', target: process.env.TRANSFER_TARGET }] },
{ dialstackAccount: accountId }
);
DialStack closes the media socket, the relay closes the Telnyx conversation, and the caller is connected to the target. The relay answers the tool call (a function_call_output item) only once DialStack has accepted the request. A rejected target is reported to the assistant as an error, so it doesn't promise the caller a hand-off. See Replacing Actions.