Conversation Relay

Run a text-only AI voice bot on an Indian number. Edesy handles speech recognition, TTS, barge-in and DTMF; your WebSocket exchanges JSON text using the Twilio ConversationRelay-compatible protocol.

4 min read

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#

  1. Set last: true on 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.
  2. 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 to utteranceUntilInterrupt.
  3. 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.

Was this page helpful?