Skip to content

Streaming into your app

The Quickstart shows the smallest working client. This page covers the production shape: a mint proxy on your server, LiveKit room join on the client, agent-track subscription, chat over the LiveKit data channel, reconnect, and the common failure modes.

+------------------+ +----------------+ +---------------------+
| Your client | | Your server | | 2wai API |
| | | (mint proxy) | | (api.2wai.ai) |
+--------+---------+ +--------+-------+ +----------+----------+
| | |
| 1. POST /mint-proxy | |
| -------------------------> | 2. POST /v1/sessions |
| | --------------------------> |
| | |
| | <-- livekit_url, token |
| <-- livekit_url, token | |
| |
| 3. join room (livekit_url, participant_token) |
| ----------------------------------------------------------|
| |
| <-- agent participant appears, video + audio tracks |
| |
| 4. send user text over data channel topic "lk.chat" |
| ----------------------------------------------------------|
| |
| <-- agent replies: audio speaks, data-channel reply text |
| |

Three distinct hops:

  1. Mint proxy (your server) — holds the sk_, exchanges it for a per-session participant_token.
  2. LiveKit room join (your client → LiveKit cloud) — WebRTC transport, standard LiveKit SDK.
  3. Chat envelope over data channel — user turns go out as JSON on topic lk.chat, reply chunks come back the same way.

The simplest Node mint proxy:

mint-proxy.js
import express from 'express';
const app = express();
app.use(express.json());
app.post('/mint', async (req, res) => {
const r = await fetch('https://api.2wai.ai/v1/sessions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TWAI_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
avatar_id: req.body.avatar_id, // or hardcode on the proxy
voice_id: req.body.voice_id,
mode: 'video',
transport: 'livekit',
}),
});
if (!r.ok) return res.status(r.status).json(await r.json());
const s = await r.json();
// Return only the fields the client needs to join the room.
res.json({
session_id: s.session_id,
livekit_url: s.livekit_url,
participant_token: s.participant_token,
room_name: s.room_name,
});
});
app.listen(3000);

Reference impls for Node, Python, and Cloudflare Worker are in 2waiorg/2wai-toolkit-public.

client.html
<video id="avatar" autoplay playsinline style="max-width: 480px"></video>
<script src="https://cdn.jsdelivr.net/npm/livekit-client/dist/livekit-client.umd.min.js"></script>
<script type="module">
const { Room, RoomEvent, DataPacket_Kind } = LivekitClient;
const video = document.getElementById('avatar');
async function connect() {
const res = await fetch('/mint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ avatar_id: 'avt_...' }),
});
const { livekit_url, participant_token } = await res.json();
// Mandatory for 2wai: adaptiveStream + dynacast MUST be off.
const room = new Room({ adaptiveStream: false, dynacast: false });
room.on(RoomEvent.TrackSubscribed, (track) => {
if (track.kind === 'video') track.attach(video);
else if (track.kind === 'audio') track.attach();
});
room.on(RoomEvent.DataReceived, (payload, participant, kind, topic) => {
if (topic === 'lk.chat') {
const frame = JSON.parse(new TextDecoder().decode(payload));
if (frame.text) console.log('avatar reply:', frame.text, 'final:', frame.final);
}
});
await room.connect(livekit_url, participant_token);
return room;
}
// Send a user text turn over the data channel.
async function send(room, text) {
const envelope = {
type: 'user_text',
turn_id: crypto.randomUUID(),
text,
};
const data = new TextEncoder().encode(JSON.stringify(envelope));
await room.localParticipant.publishData(data, { reliable: true, topic: 'lk.chat' });
}
const room = await connect();
window.send = (text) => send(room, text);
</script>

The chat envelope (data channel topic lk.chat)

Section titled “The chat envelope (data channel topic lk.chat)”

User-text (client → agent):

{"type":"user_text","turn_id":"<uuid>","text":"Hi, tell me about yourself.","extra":{}}

Reply-frame (agent → client, one or more per turn):

{"type":"reply","turn_id":"<uuid>","text":"Hi — I'm Dr. Alman.","final":false}

The final: true frame marks the end of a reply turn. The agent’s audio track plays the voiced version of the same text; the data-channel reply is for your UI (showing the reply text alongside the video, or feeding analytics / logs).

LiveKit’s RoomEvent.ActiveSpeakersChanged fires when the agent participant starts/stops producing audio. Use it to gate your Send button (don’t let the user interrupt themselves) and to animate the UI (e.g. “listening” vs “speaking” state).

room.on(RoomEvent.ActiveSpeakersChanged, (speakers) => {
const agentActive = speakers.some(p => p.identity.startsWith('livealman') || p.isRemote);
// update UI
});

Your mint proxy routes /say to the gateway’s /speak endpoint using your sk_:

app.post('/say', async (req, res) => {
const { session_id, text } = req.body;
const r = await fetch(`https://api.2wai.ai/v1/sessions/${session_id}/speak`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TWAI_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text }),
});
res.status(r.status).json(await r.json());
});

Call /interrupt the moment you detect new user input to cut off a stale reply — the LiveKit audio track mutes, the agent gets ready for the next turn.

LiveKit SDKs handle network blips automatically. For longer drops (token expired, session ended), re-mint via POST /v1/sessions and re-connect the room with the fresh token:

async function reconnect() {
await room.disconnect();
const { livekit_url, participant_token } = await (await fetch('/mint', { method: 'POST' })).json();
await room.connect(livekit_url, participant_token);
}

Exponential backoff on retry (1s, 2s, 4s, cap at 30s). SDKs ship this; raw clients should implement it.

SymptomLikely causeFix
Mint returns 401sk_ wrong / revokedVerify curl ... /v1/sessions with the sk_ directly (not via proxy)
Mint 429Tenant cap hitBack off per retry-after header
Room connects but no video appearsadaptiveStream not disabledPass new Room({ adaptiveStream: false, dynacast: false })
Avatar silent after speakTTS voice unknown on your tenantSee Voices — register a voice first
First user turn clipped mid-sentenceSent before agent warm-up windowSDKs honor a 2-4s SEND_GRACE_MS window after agent-ready; raw clients should wait a beat after first subscribe before sending