> ## Documentation Index
> Fetch the complete documentation index at: https://slng.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Embed on your website

> Add a browser-based voice session with a SLNG voice agent to any web page using LiveKit, a React frontend, and a backend proxy that hides your SLNG key.

You can let visitors talk to an SLNG voice agent directly in the browser. You need a small backend to create the web session (keeps your SLNG key off the client) and a frontend that connects to LiveKit for real-time audio.

## Placeholders

The snippets below use these placeholders. Replace them with your own values before running the code.

| Placeholder | Replace with |
| - | - |
| `SLNG_API_KEY` | An SLNG key from [app.slng.ai/api-keys](https://app.slng.ai/api-keys) |
| `SLNG_AGENT_ID` | The ID of a configured [Voice Agent](https://slng.mintlify.site/voice-agents.md) |

## Prerequisites

* A configured [Voice Agent](https://slng.mintlify.site/voice-agents.md) with its agent ID
* An SLNG key (get one at [app.slng.ai](https://app.slng.ai/api-keys))
* A backend you can deploy server-side code to (Node.js, Deno, Python, etc.)
* A frontend project with React (the examples below use React, but the LiveKit client SDK works with any framework)

## How it works

```mermaid theme={null}
sequenceDiagram
    participant Browser
    participant Backend as Your backend
    participant SLNG as Voice Agents API
    participant LK as LiveKit room

    Browser->>Backend: POST /api/session
    Backend->>SLNG: POST /v1/agents/SLNG_AGENT_ID/web-sessions
    SLNG-->>Backend: livekit_url + livekit_token
    Backend-->>Browser: livekit_url + livekit_token
    Browser->>LK: Connect (token)
    Browser->>LK: Publish mic audio
    LK-->>Browser: Agent audio + transcripts
```

1. The browser asks your backend to start a session.
2. Your backend calls the Voice Agents web-sessions endpoint and forwards the LiveKit credentials back.
3. The browser connects to the LiveKit room, publishes the mic, and plays the agent's audio.

## Step 1: Create a backend endpoint

Your backend proxies the Voice Agents API so the SLNG key never reaches the browser.

> [!WARNING]
>
> Never call the Voice Agents API directly from client-side code. Your `SLNG_API_KEY` must stay server-side.

The only call you need is:

```text theme={null}
POST https://api.agents.slng.ai/v1/agents/SLNG_AGENT_ID/web-sessions
```

```javascript Node.js (Express) theme={null}
import express from "express";

const app = express();
app.use(express.json());

const SLNG_API_KEY = process.env.SLNG_API_KEY;
const SLNG_AGENT_ID = process.env.SLNG_AGENT_ID;

app.post("/api/session", async (req, res) => {
  const response = await fetch(
    `https://api.agents.slng.ai/v1/agents/${SLNG_AGENT_ID}/web-sessions`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        Authorization: `Bearer ${SLNG_API_KEY}`,
      },
      body: JSON.stringify({
        arguments: {},
        ...(req.body.participant_name
          ? { participant_name: req.body.participant_name }
          : {}),
      }),
    }
  );

  const data = await response.json();
  if (!response.ok) {
    return res.status(response.status).json({ error: "SLNG error", details: data });
  }
  res.json(data);
});

app.listen(3001);
```

```python Python (FastAPI) theme={null}
import os
import httpx
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

SLNG_API_KEY = os.environ["SLNG_API_KEY"]
SLNG_AGENT_ID = os.environ["SLNG_AGENT_ID"]

class SessionRequest(BaseModel):
    participant_name: str | None = None

@app.post("/api/session")
async def create_session(body: SessionRequest):
    async with httpx.AsyncClient() as client:
        payload = {"arguments": {}}
        if body.participant_name:
            payload["participant_name"] = body.participant_name

        r = await client.post(
            f"https://api.agents.slng.ai/v1/agents/{SLNG_AGENT_ID}/web-sessions",
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {SLNG_API_KEY}",
            },
            json=payload,
        )
    return r.json()
```

The response includes the fields you need for the frontend:

```json theme={null}
{
  "livekit_url": "wss://...",
  "livekit_token": "...",
  "call_id": "...",
  "max_session_seconds": 300
}
```

## Step 2: Install the LiveKit client SDK

```bash theme={null}
npm install livekit-client
```

## Step 3: Connect to the LiveKit room

Call your backend to get a session, then connect to the room:

```tsx theme={null}
import { Room, RoomEvent, createLocalAudioTrack } from "livekit-client";

async function startSession() {
  // 1. Get session credentials from your backend
  const res = await fetch("/api/session", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ participant_name: "visitor" }),
  });
  const session = await res.json();

  // 2. Create and connect to the LiveKit room
  const room = new Room({ adaptiveStream: true, dynacast: true });
  await room.connect(session.livekit_url, session.livekit_token);

  // 3. Publish your microphone
  const micTrack = await createLocalAudioTrack();
  await room.localParticipant.publishTrack(micTrack);

  return { room, micTrack, session };
}
```

> [!TIP]
>
> The browser will prompt the user for microphone access on `createLocalAudioTrack()`. If your page is not served over HTTPS, most browsers will block the request.

> [!NOTE]
>
> Noise cancellation is applied automatically on the server: the agent runs background voice cancellation on web participants' audio before transcription. You don't need any client-side audio processing or filter libraries.

## Step 4: Play the agent's audio

Attach the agent's remote audio track to the DOM so the browser plays it:

```tsx theme={null}
room.on(RoomEvent.TrackSubscribed, (track) => {
  if (track.kind !== "audio") return;
  const el = track.attach();
  el.autoplay = true;
  document.body.appendChild(el);
});

room.on(RoomEvent.TrackUnsubscribed, (track) => {
  track.detach().forEach((el) => el.remove());
});
```

## Step 5: Handle real-time messages

The agent publishes real-time events over a LiveKit data channel on the `slng.transcript.v1` topic. Every message is a UTF-8-encoded JSON object with a common envelope: `v` (payload version, currently `1`), `type`, and `created_at` (Unix timestamp in seconds):

```tsx theme={null}
const TRANSCRIPT_TOPIC = "slng.transcript.v1";

room.on(RoomEvent.DataReceived, (payload, _participant, _kind, topic) => {
  if (topic !== TRANSCRIPT_TOPIC) return;

  const msg = JSON.parse(new TextDecoder().decode(payload));

  switch (msg.type) {
    case "conversation_item_added": // completed turn → transcript view
    case "user_input_transcribed": // interim + final STT → live captions
    case "agent_state_changed": // listening / thinking / speaking → persona
    case "user_state_changed": // user started or stopped speaking
    case "close": // the agent ended the session
    default: // ignore unknown types — new ones may be added
  }
});
```

| `type` | Sent when | Use it for |
| - | - | - |
| `conversation_item_added` | A completed user or agent turn is added to the conversation history | The transcript view |
| `user_input_transcribed` | Speech-to-text produces an interim or final transcription of user speech | Live captions, detected language |
| `agent_state_changed` | The agent transitions between `initializing`, `idle`, `listening`, `thinking`, and `speaking` | Persona or status indicator |
| `user_state_changed` | The user transitions between `speaking`, `listening`, and `away` | Mic activity indicator |
| `close` | The agent ends the session | Showing "call ended" and cleaning up |
| `debug` | A diagnostic event occurs (only sent when room-data debugging is enabled for your deployment) | Troubleshooting |

> [!NOTE]
>
> There is one more message type, `call_event` (`level`, `stage`, and optional `data`) — customer-safe call lifecycle events. It shares the same envelope but is recorded with the call on the SLNG backend, not published over the room data channel, so your page never receives it.

### conversation\_item\_added

Sent when a completed turn — from the user or the agent — is added to the conversation history:

```json theme={null}
{
  "v": 1,
  "type": "conversation_item_added",
  "created_at": 1755043201.53,
  "item": {
    "id": "item_a1b2c3",
    "role": "assistant",
    "type": "message",
    "content": ["Sure — I can help with that."],
    "created_at": 1755043201.53,
    "interrupted": false,
    "transcript_confidence": null,
    "metrics": {},
    "extra": {},
    "hash": null
  }
}
```

| Field | Description |
| - | - |
| `item.id` | Unique message ID (use to deduplicate) |
| `item.role` | `"user"` or `"assistant"` |
| `item.type` | Always `"message"` |
| `item.content` | The turn's text as an array of strings — join for display |
| `item.created_at` | Unix timestamp (seconds) |
| `item.interrupted` | `true` if the user barged in and the agent's turn was cut off |
| `item.transcript_confidence` | Speech-to-text confidence for user turns (number, or `null`) |
| `item.metrics`, `item.extra`, `item.hash` | Auxiliary metadata — safe to ignore |

```tsx theme={null}
if (msg.type === "conversation_item_added" && msg.item) {
  const { id, role, content, created_at } = msg.item;
  const text = content.join("\n");
  // Append { id, role, content: text, created_at } to your transcript state
}
```

### user\_input\_transcribed

Streams the speech-to-text output while the user is talking. You receive a series of interim messages (`is_final: false`), each carrying the transcript of the utterance so far, followed by one final message (`is_final: true`):

```json theme={null}
{
  "v": 1,
  "type": "user_input_transcribed",
  "is_final": false,
  "transcript": "I'd like to book a",
  "language": "en",
  "speaker_id": null,
  "created_at": 1755043195.42
}
```

| Field | Description |
| - | - |
| `is_final` | `false` for interim results, `true` when the utterance is complete |
| `transcript` | The transcribed text so far (replaces, not appends to, earlier interims) |
| `language` | Detected language of the user's speech, or `null` — this is how your page learns which language was detected |
| `speaker_id` | Speaker label when the STT model provides one, otherwise `null` |
| `created_at` | Unix timestamp (seconds) |

This is how you render live captions: show `transcript` while `is_final` is `false`, then clear the caption on the final message (the completed turn also arrives as `conversation_item_added`):

```tsx theme={null}
if (msg.type === "user_input_transcribed") {
  setCaption(msg.is_final ? "" : (msg.transcript ?? ""));
  if (msg.language) setDetectedLanguage(msg.language);
}
```

### agent\_state\_changed and user\_state\_changed

Sent whenever the agent or the user transitions between states:

```json theme={null}
{
  "v": 1,
  "type": "agent_state_changed",
  "old_state": "thinking",
  "new_state": "speaking",
  "created_at": 1755043200.87
}
```

| Field | Description |
| - | - |
| `old_state` | The state being left |
| `new_state` | The state being entered |
| `created_at` | Unix timestamp (seconds) |

Agent states are `initializing`, `idle`, `listening`, `thinking`, and `speaking`. User states are `speaking`, `listening`, and `away`. The agent's state is also mirrored to the `lk.agent.state` attribute on the agent participant (see Step 7).

### close

Sent when the agent ends the session — because it called its hang-up tool, the user was idle too long, the maximum session duration was reached, or something failed. This is how your page knows the call is over:

```json theme={null}
{
  "v": 1,
  "type": "close",
  "created_at": 1755043500.02,
  "reason": "normal",
  "error": null
}
```

| Field | Description |
| - | - |
| `reason` | `"normal"` for a graceful end; otherwise the error text |
| `error` | `null` on a graceful end; the error message when the session closed due to a failure |
| `created_at` | Unix timestamp (seconds) |

The room disconnects shortly afterwards, so treat `close` as your "call ended" signal: stop the mic, update the UI, and don't offer reconnect-style retries for `reason: "normal"`.

### debug

Verbose diagnostics (pipeline stages, errors with stack traces). These are only published to the room when room-data debugging is enabled for your deployment — off by default, so don't build UI on them:

| Field | Description |
| - | - |
| `level` | `"debug"`, `"info"`, `"warning"`, or `"error"` |
| `stage` | Pipeline stage that emitted the event (for example `session.close`) |
| `message` | Human-readable description (optional) |
| `data` | Structured details (optional) |
| `error`, `stack` | Error text and stack trace when the event carries an exception (optional) |
| `context` | Object with `room_name`, `call_id`, `job_id`, `agent_id`, and `agent_name` |

## Step 6: Add mute and disconnect controls

```tsx theme={null}
// Toggle mute
async function toggleMute(micTrack, muted) {
  if (muted) {
    await micTrack.unmute();
  } else {
    await micTrack.mute();
  }
  return !muted;
}

// End the session
async function disconnect(room, micTrack) {
  micTrack.stop();
  await room.disconnect();
}
```

## Step 7: Track what the agent is doing

Use the `agent_state_changed` data message from Step 5 to know whether the agent is listening, thinking, or speaking, and drive a visual indicator or avatar animation from it. Don't infer this from audio levels (`RoomEvent.ActiveSpeakersChanged`) — they can tell you the agent is talking, but they can't distinguish *thinking* from *idle*:

```tsx theme={null}
type AgentState = "initializing" | "idle" | "listening" | "thinking" | "speaking";

room.on(RoomEvent.DataReceived, (payload, _participant, _kind, topic) => {
  if (topic !== TRANSCRIPT_TOPIC) return;
  const msg = JSON.parse(new TextDecoder().decode(payload));

  if (msg.type === "agent_state_changed") {
    setAgentState(msg.new_state as AgentState);
  }
});
```

The same state is also mirrored to the `lk.agent.state` attribute on the agent participant, so as an alternative you can read it without parsing data messages:

```tsx theme={null}
room.on(RoomEvent.ParticipantAttributesChanged, (changed, participant) => {
  if (changed["lk.agent.state"]) {
    setAgentState(changed["lk.agent.state"] as AgentState);
  }
});
```

## Optional: Add a visual persona

A voice-only interface gives users no visual cue about what the agent is doing. Adding an animated persona (an orb, waveform, or avatar) makes the experience feel more responsive. Two ready-made libraries work well here:

> **[Vercel AI SDK Persona](https://elements.ai-sdk.dev/components/persona)**
>
> A React component with built-in states: `idle`, `listening`, `speaking`, `thinking`. Drop it in and map LiveKit events to states.

> **[ElevenLabs Conversational UI](https://ui.elevenlabs.io/docs/components)**
>
> Orb and avatar components designed for voice interfaces, with audio-reactive animations.

To wire either library up, map the agent state from Step 7 to a persona state — the states line up almost one-to-one:

```tsx theme={null}
// Derive a persona state from the agent state + your session state
function getPersonaState({ status, muted, agentState }) {
  if (status !== "active") return "idle";
  if (agentState === "speaking") return "speaking";
  if (agentState === "thinking") return "thinking";
  if (agentState === "listening") return muted ? "idle" : "listening";
  return "idle"; // "initializing" or "idle"
}

// Update on agent_state_changed messages (Step 7)
room.on(RoomEvent.DataReceived, (payload, _participant, _kind, topic) => {
  if (topic !== TRANSCRIPT_TOPIC) return;
  const msg = JSON.parse(new TextDecoder().decode(payload));

  if (msg.type === "agent_state_changed") {
    setPersonaState(getPersonaState({ status, muted, agentState: msg.new_state }));
  }
});
```

## Putting it all together

A minimal React component with all the steps above wired together:

```tsx theme={null}
import { useEffect, useRef, useState, useCallback } from "react";
import {
  Room,
  RoomEvent,
  createLocalAudioTrack,
  type LocalAudioTrack,
  type RemoteTrack,
} from "livekit-client";

const TRANSCRIPT_TOPIC = "slng.transcript.v1";

interface SessionData {
  livekit_url: string;
  livekit_token: string;
  call_id: string;
  max_session_seconds: number;
}

interface TranscriptItem {
  id: string;
  role: "user" | "assistant";
  content: string;
}

type AgentState = "initializing" | "idle" | "listening" | "thinking" | "speaking";
type UserState = "speaking" | "listening" | "away";

// Messages on the slng.transcript.v1 data channel (see Step 5)
type DataMessage =
  | {
      type: "conversation_item_added";
      created_at: number;
      item: {
        id: string;
        role: "user" | "assistant";
        content: string[];
        created_at: number;
        interrupted: boolean;
        transcript_confidence: number | null;
      };
    }
  | {
      type: "user_input_transcribed";
      is_final: boolean;
      transcript: string | null;
      language: string | null;
      speaker_id: string | null;
      created_at: number;
    }
  | {
      type: "agent_state_changed";
      old_state: AgentState;
      new_state: AgentState;
      created_at: number;
    }
  | {
      type: "user_state_changed";
      old_state: UserState;
      new_state: UserState;
      created_at: number;
    }
  | { type: "close"; reason: string; error: string | null; created_at: number }
  | {
      type: "debug";
      level: "debug" | "info" | "warning" | "error";
      stage: string;
      message?: string;
      data?: Record<string, unknown>;
      created_at: number;
    };

export default function VoiceSession() {
  const [status, setStatus] = useState<"idle" | "connecting" | "active" | "ended">("idle");
  const [muted, setMuted] = useState(false);
  const [transcript, setTranscript] = useState<TranscriptItem[]>([]);
  const [agentState, setAgentState] = useState<AgentState>("initializing");
  const [caption, setCaption] = useState("");
  const [endReason, setEndReason] = useState<"ended" | "error" | null>(null);
  const roomRef = useRef<Room | null>(null);
  const micRef = useRef<LocalAudioTrack | null>(null);

  const start = useCallback(async () => {
    setStatus("connecting");

    // Get session from your backend
    const res = await fetch("/api/session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({}),
    });
    const session: SessionData = await res.json();

    const room = new Room({ adaptiveStream: true, dynacast: true });
    roomRef.current = room;

    // Play agent audio
    room.on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => {
      if (track.kind !== "audio") return;
      const el = track.attach() as HTMLAudioElement;
      el.autoplay = true;
      document.body.appendChild(el);
    });

    room.on(RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {
      track.detach().forEach((el) => el.remove());
    });

    // Data-channel messages: transcript, captions, agent state, session end
    room.on(
      RoomEvent.DataReceived,
      (payload: Uint8Array, _p: unknown, _k: unknown, topic?: string) => {
        if (topic !== TRANSCRIPT_TOPIC) return;
        try {
          const msg = JSON.parse(new TextDecoder().decode(payload)) as DataMessage;

          switch (msg.type) {
            case "conversation_item_added": {
              const { id, role, content } = msg.item;
              const text = content.join("\n");
              setTranscript((prev) =>
                prev.find((t) => t.id === id) ? prev : [...prev, { id, role, content: text }]
              );
              break;
            }
            case "user_input_transcribed":
              // Live caption while the user speaks; the final text lands in the transcript
              setCaption(msg.is_final ? "" : (msg.transcript ?? ""));
              break;
            case "agent_state_changed":
              setAgentState(msg.new_state);
              break;
            case "close":
              // The agent ended the session (hang-up tool, idle timeout,
              // max session duration, or a failure)
              setEndReason(msg.error ? "error" : "ended");
              setStatus("ended");
              break;
            default:
              break; // ignore unknown message types
          }
        } catch {
          // ignore malformed messages
        }
      }
    );

    room.on(RoomEvent.Disconnected, () => setStatus("ended"));

    await room.connect(session.livekit_url, session.livekit_token);

    const micTrack = await createLocalAudioTrack();
    micRef.current = micTrack;
    await room.localParticipant.publishTrack(micTrack);

    setStatus("active");
  }, []);

  const toggleMute = async () => {
    if (!micRef.current) return;
    muted ? await micRef.current.unmute() : await micRef.current.mute();
    setMuted((m) => !m);
  };

  const disconnect = useCallback(async () => {
    micRef.current?.stop();
    micRef.current = null;
    await roomRef.current?.disconnect();
    roomRef.current = null;
    setStatus("ended");
  }, []);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      micRef.current?.stop();
      roomRef.current?.disconnect();
    };
  }, []);

  return (
    <div>
      {status === "idle" && <button onClick={start}>Start conversation</button>}
      {status === "connecting" && <p>Connecting…</p>}

      {status === "active" && (
        <div>
          <p>Agent is {agentState}…</p>
          <button onClick={toggleMute}>{muted ? "Unmute" : "Mute"}</button>
          <button onClick={disconnect}>End call</button>
        </div>
      )}

      {status === "ended" && (
        <p>{endReason === "error" ? "Call ended due to an error." : "Call ended."}</p>
      )}

      <ul>
        {transcript.map((item) => (
          <li key={item.id}>
            <strong>{item.role}:</strong> {item.content}
          </li>
        ))}
        {caption && (
          <li>
            <strong>user:</strong> <em>{caption}</em>
          </li>
        )}
      </ul>
    </div>
  );
}
```

## Next steps

* Set up your agent with custom prompts and tools in the [Dashboard](https://slng.mintlify.site/dashboard/agent-infra.md)
* See the [Voice Agents API](https://slng.mintlify.site/voice-agents.md) for agent configuration options
* Add phone call support with [Telephony](https://slng.mintlify.site/dashboard/telephony.md)
* Add a visual persona with [Vercel AI SDK](https://elements.ai-sdk.dev/components/persona) or [ElevenLabs UI](https://ui.elevenlabs.io/docs/components)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.