# 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.

Source: https://edesy.in/docs/voice-api/streaming/conversation-relay

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:

```bash
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](https://edesy.in/docs/voice-api/streaming/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` |

```json
{ "type": "text", "token": "We are open from nine to seven.", "last": true }
```

```json
{ "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.

```bash
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 | yes | `ws://` or `wss://` URL |
| `prompt` | string | no | Text to send as the caller's utterance, up to 500 characters |
| `seconds` | integer | no | How long to wait for a reply, 1 to 15 |

```json
{
  "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

```js
// 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](https://edesy.in/contact) for the rate before you plan volume.
