Conversation Relay puts your own LLM bot on an Edesy phone number without any audio code. Edesy transcribes the caller, sends your WebSocket the text, and speaks the text your bot sends back, handling speech recognition, speech synthesis, barge-in and DTMF. The message protocol is compatible with Twilio ConversationRelay, so a bot written for Twilio works here unchanged. It is live in production.
Point a number at your bot#
In the portal, open the number and under When a call comes in choose the AI voice bot (text in, text out) option, paste your wss:// URL, optionally set a greeting, voice and language, press Test this endpoint, and save.
With the API:
curl -X PUT https://voice-api.edesy.in/v1/phone-numbers/{id}/inbound \
-H "Authorization: Bearer $EDESY_KEY" \
-H "Content-Type: application/json" \
-d '{
"target": "conversation_relay",
"relay_url": "wss://bot.example.com/relay",
"relay_settings": {
"welcome_greeting": "Hi, thanks for calling Acme. How can I help?",
"tts_language": "en-IN",
"stt_language": "en-IN",
"interruptible": true,
"min_bargein_word_count": 1,
"dtmf_detection": true,
"custom_parameters": { "store": "pune" }
}
}'
The same can be done with PATCH/v1/phone-numbers/{id}/routing using inbound_target_type: "relay", inbound_relay_url and inbound_relay_config. Conversation Relay is an inbound target; for outbound bot calls today, use Media Streams with stream_url.
Settings#
| Field | Default | Description |
|---|---|---|
welcome_greeting |
none | Spoken as soon as the call connects. Omit it to let your bot speak first |
tts_voice |
platform default | Voice ID for speech synthesis |
tts_language |
en-IN |
Language for speech synthesis |
stt_language |
en-IN |
Language for speech recognition |
interruptible |
true |
Whether the caller can talk over the bot |
min_bargein_word_count |
1 |
Words the caller must say to interrupt. 0 interrupts on any voice activity: fastest, but a cough stops the bot |
dtmf_detection |
false |
Keypresses interrupt the bot and arrive as dtmf messages |
custom_parameters |
none | Key-value strings passed through in setup |
Sending the settings object replaces the stored one; it is not merged.
Protocol#
Every frame is a JSON text frame with a type.
Edesy to your bot#
type |
When | Fields |
|---|---|---|
setup |
Once, before anything is spoken | sessionId, callSid, from, to, direction, callType, customParameters |
prompt |
The caller finished an utterance | voicePrompt (the transcript), lang, last |
interrupt |
The caller talked over the bot | utteranceUntilInterrupt, durationUntilInterruptMs |
dtmf |
One keypress (when dtmf_detection is on) |
digit |
error |
A platform-side problem; informational, the call usually continues | description |
Your bot to Edesy#
type |
Effect | Fields |
|---|---|---|
text |
Speak it. Stream tokens as your LLM produces them | token, last, interruptible, preemptible |
play |
Play an audio file, then resume the conversation | source, loop |
sendDigits |
Send DTMF out of the call (w is a half-second wait) |
digits |
language |
Switch language mid-call | ttsLanguage, transcriptionLanguage |
end |
End the call; handoffData is recorded as a call event |
handoffData |
{ "type": "text", "token": "We are open from nine to seven.", "last": true }
{ "type": "end", "handoffData": "{\"reason\":\"caller_asked_for_human\"}" }
Three things that bite#
- Set
last: trueon the final token of every turn. It flushes the synthesizer. Without it the end of each reply is never spoken and the bot sounds like it swallows its last sentence. - Handle
interrupt. When the caller barges in, unplayed speech is discarded, so your history contains words the caller never heard. Trim the last bot turn toutteranceUntilInterrupt. - Accept the WebSocket upgrade quickly. The connection attempt is bounded (about 3 seconds) and a fallback line is spoken if it expires. Authenticate at the upgrade, not after it.
Preflight: POST/v1/relay-test#
Connects to your bot from the Edesy network, sends a setup and one prompt, and returns a checklist.
curl -X POST https://voice-api.edesy.in/v1/relay-test \
-H "Authorization: Bearer $EDESY_KEY" \
-H "Content-Type: application/json" \
-d '{ "relay_url": "wss://bot.example.com/relay", "prompt": "what are your hours", "seconds": 5 }'
| Field | Type | Required | Description |
|---|---|---|---|
relay_url |
string | Required | ws:// or wss:// URL |
prompt |
string | Optional | Text to send as the caller's utterance, up to 500 characters |
seconds |
integer | Optional | How long to wait for a reply, 1 to 15 |
{
"ok": true,
"relay_url": "wss://bot.example.com/relay",
"steps": [
{ "step": "dns", "ok": true, "detail": "..." },
{ "step": "tcp", "ok": true, "detail": "..." },
{ "step": "tls", "ok": true, "detail": "..." },
{ "step": "upgrade", "ok": true, "detail": "..." },
{ "step": "protocol", "ok": true, "detail": "..." }
],
"reply_text": "We are open from nine to seven, Monday to Saturday.",
"text_messages": 9,
"saw_last_token": true
}
The protocol step passes when your bot replies with at least one text. A reply with no last: true is reported as a hint, not a failure, because the bot works but will sound broken on a call. unknown_messages lists any message types the test did not recognise. Private and local addresses are refused.
Call events#
Readable on GET/v1/calls/{CallSid}/events:
| Event | Meaning |
|---|---|
call.relay.connected |
Edesy reached your bot |
call.relay.failed |
It did not, or the connection dropped mid-call. Carries the reason |
call.relay.handoff |
Your bot sent end, with its handoffData |
call.relay.usage |
Speech usage for the call: TTS characters, STT seconds, turns, interruptions |
Example: Node.js bot#
// npm install ws
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: process.env.PORT || 8787, path: '/relay' });
wss.on('connection', (ws) => {
let session = null;
const history = [];
ws.on('message', async (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
switch (msg.type) {
case 'setup':
session = msg; // callSid, from, to, customParameters
break;
case 'prompt': {
history.push({ role: 'user', content: msg.voicePrompt });
const reply = await askYourLLM(history, session?.customParameters);
history.push({ role: 'assistant', content: reply });
speak(ws, reply);
if (/goodbye/i.test(reply)) {
ws.send(JSON.stringify({ type: 'end', handoffData: JSON.stringify({ reason: 'done' }) }));
}
break;
}
case 'interrupt': {
// The caller heard only part of the last reply.
const last = history[history.length - 1];
if (last && last.role === 'assistant') last.content = msg.utteranceUntilInterrupt;
break;
}
case 'dtmf':
speak(ws, `You pressed ${msg.digit}.`);
break;
case 'error':
console.error('relay error:', msg.description);
break;
}
});
});
// Stream word by word, the way an LLM emits tokens. `last` flushes the synthesizer.
function speak(ws, text) {
const words = text.split(' ');
words.forEach((w, i) => ws.send(JSON.stringify({
type: 'text',
token: i === 0 ? w : ` ${w}`,
last: i === words.length - 1,
})));
}
async function askYourLLM(history, params) {
// Replace with a call to your LLM of choice.
return 'We are open from nine in the morning to seven in the evening. Goodbye!';
}
Expose it over wss://, run POST/v1/relay-test, then point a number at it.
Pricing#
With Conversation Relay, Edesy runs the speech recognition and synthesis, so it is priced separately from plain call minutes. Ask us through /contact for the rate before you plan volume.