Echo Sessions

Stream your own audio and let the agent speak it

Echo sessions let your backend stream speech audio directly into a live agent session: the avatar lip-syncs your audio in real time, skipping STT, LLM and TTS entirely. You own the conversation logic and the voice; D-ID renders the talking avatar and streams it to your users. Echo is available for v4 expressive avatar agents.

Use echo when you already have an audio source, like your own voice agent, a custom TTS pipeline, recorded audio, or a human speaker, and want a D-ID avatar to present it.

An echo session is created through the same Create Session V2 endpoint you may already use for agent sessions; the only difference in the request is session_type: "echo", and the only difference in the response is the extra echo_token.

📘

Note

The audio itself is streamed over LiveKit. Your backend joins the session's LiveKit room with the echo_token and sends the audio through a LiveKit realtime SDK; see LiveKit's docs for the available languages.

How it works

  1. Your backend creates a session with session_type: "echo" and receives an echo_token in addition to the regular frontend credentials.
  2. Your frontend joins the session with session_url + session_token, exactly as in the LiveKit quickstart.
  3. Your backend joins the same LiveKit room with the echo_token and streams audio over a byte stream. The avatar speaks it.

Echo sessions are in echo mode from the moment they are created: the end user's microphone never feeds the agent, and speak/chat requests are rejected for the whole session.

Create an echo session

Call the sessions endpoint from your backend with your API key and session_type: "echo".

curl -X POST "https://api.d-id.com/v2/agents/<AGENT_ID>/sessions" \
  -H "Authorization: Basic <YOUR KEY>" \
  -H "Content-Type: application/json" \
  -d '{
    "session_type": "echo"
  }'
{
  "id": "sess_abc123",
  "session_url": "wss://<livekit-host>/room/agent-agt_abc123-sess_abc123",
  "session_token": "eyJhbGciOi...",
  "interrupt_enabled": true,   // user-interruption flag, not relevant in echo
  "echo_token": "eyJhbGciOi..."
}

Forward session_url and session_token to your frontend and join the room as shown in the LiveKit quickstart. Keep echo_token on your backend only - it identifies your backend as the session's audio sender. See Create Session V2 for the full endpoint reference.

Join the room from your backend

Connect to the room with a server-side LiveKit realtime SDK. The LiveKit server URL is the host part of session_url (everything before /room/). Before streaming, wait until the avatar's tracks are subscribed (see the full example below): audio sent while the avatar is still starting up is dropped.

uv add livekit  # or: pip install livekit
from livekit import rtc

room = rtc.Room()
livekit_url = session["session_url"].split("/room/")[0]
await room.connect(livekit_url, session["echo_token"])

Stream an utterance

Open a byte stream on the did.audio-stream topic addressed to the agent participants, declare the audio format in the stream attributes, write the audio, and close the stream. One stream is one utterance: closing it tells the avatar the utterance is complete.

avatar_identities = [
    participant.identity
    for participant in room.remote_participants.values()
    if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
    or participant.identity.startswith("agent")
]
writer = await room.local_participant.stream_bytes(
    name="utterance-1",
    topic="did.audio-stream",
    attributes={"format": "wav"},  # or "mp3", or "pcm16" (see formats below)
    destination_identities=avatar_identities,
)
with open("speech.wav", "rb") as f:
    await writer.write(f.read())
await writer.aclose()

For raw PCM, declare the layout explicitly:

writer = await room.local_participant.stream_bytes(
    name="utterance-2",
    topic="did.audio-stream",
    attributes={
        "format": "pcm16",
        "sample_rate": "24000",  # min 8000
        "channels": "1",         # 1 or 2
    },
    destination_identities=avatar_identities,
)

To say several things in a row, open a new stream per utterance. Utterances play in order, each one after the previous one finishes. You can send audio faster than real time; when you run too far ahead, write() simply waits until the session catches up.

Interrupt when needed

To make a new utterance cut whatever is currently playing instead of queueing behind it, set the interrupt attribute on its stream.

writer = await room.local_participant.stream_bytes(
    name="utterance-3",
    topic="did.audio-stream",
    attributes={"format": "wav", "interrupt": "true"},
    destination_identities=avatar_identities,
)

To just stop playback without sending new audio, send a did.interrupt text message:

await room.local_participant.send_text("{}", topic="did.interrupt")

Full example

A minimal backend that creates an echo session, streams one WAV file, and leaves. Open the printed watch link in a browser to see the avatar speak it.

import asyncio

import requests
from livekit import rtc

API_KEY = "<YOUR KEY>"
AGENT_ID = "<AGENT_ID>"


async def main() -> None:
    session = requests.post(
        f"https://api.d-id.com/v2/agents/{AGENT_ID}/sessions",
        headers={"Authorization": f"Basic {API_KEY}"},
        json={"session_type": "echo"},
    ).json()
    livekit_host = session["session_url"].split("/room/")[0]
    print(  # test viewer; your real frontend joins with the LiveKit SDK
        f"watch: https://meet.livekit.io/custom"
        f"?liveKitUrl={livekit_host}&token={session['session_token']}"
    )

    room = rtc.Room()
    avatar_ready = asyncio.Event()
    room.on("track_subscribed", lambda *_: avatar_ready.set())
    await room.connect(livekit_host, session["echo_token"])
    await avatar_ready.wait()  # the avatar accepts audio once its tracks are up
    avatar_identities = [
        participant.identity
        for participant in room.remote_participants.values()
        if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
        or participant.identity.startswith("agent")
    ]

    writer = await room.local_participant.stream_bytes(
        name="utterance-1",
        topic="did.audio-stream",
        attributes={"format": "wav"},
        destination_identities=avatar_identities,
    )
    with open("speech.wav", "rb") as f:
        await writer.write(f.read())
    await writer.aclose()

    await asyncio.sleep(30)  # stay connected while you watch (a real backend stays connected anyway)
    await room.disconnect()


asyncio.run(main())

Audio formats

FormatAttributes
pcm16sample_rate (required, min 8000), channels (1 or 2)
mp3none
wavnone

Utterance boundaries

  • Closing the byte stream ends the utterance - this is the recommended way.
  • Inside a long-lived stream, a gap of 1 second with no audio also ends the current utterance.
  • Only one stream may be open at a time; a concurrent stream is rejected.

Rejections

Invalid input is reported on the session's event stream as an input/rejected event carrying a source (audio or text), an identifier (stream id or topic) and a reason - for example an unsupported format, a concurrent stream, a sender that is not the session's echo sender, or a speak/chat request sent while the session is in echo mode.

Notes

  • The echo_token can only publish data (audio bytes) and subscribe; it cannot publish tracks or manage the room.
  • The session survives a page refresh in the frontend; it closes after an inactivity timeout when no audio arrives.

Did this page help you?