Powered by AppSignal & Oban Pro

LiveKit Agents — a working demo

notebooks/agents_demo.livemd

LiveKit Agents — a working demo

Mix.install([
  {:livekit, path: Path.expand("..", __DIR__), env: :dev}
])

What this is

An acceptance test you can read. Every cell below runs against the real agents branch API — no mocks of the SDK itself, no network, no API keys, and no Rust toolchain. If a cell fails, the branch is not ready for someone else to build on.

It deliberately covers the two things that were broken before: VoiceAgent starting at all, and a pipeline turn actually reaching the providers.

What it does not cover: publishing audio into a real LiveKit room. That needs the Rust NIF (native/livekit_webrtc) and a running LiveKit server. Everything else — tokens, the pipeline, the agent, chat context, events — runs here.

1. Room tokens

The one piece a web app needs on day one: a JWT that lets a browser join one room and nothing else.

grants = Livekit.Grants.join_room("demo-room")

jwt =
  "devkey"
  |> Livekit.AccessToken.new("devsecret")
  |> Livekit.AccessToken.with_identity("visitor-42")
  |> Livekit.AccessToken.with_ttl(600)
  |> Livekit.AccessToken.with_grants(grants)
  |> Livekit.AccessToken.to_jwt()

byte_size(jwt)

A token nobody can verify is not a token. Round-trip it:

{:ok, claims} = Livekit.TokenVerifier.verify(jwt, "devsecret")

%{
  identity: claims["sub"],
  room: get_in(claims, ["video", "room"]),
  can_join: get_in(claims, ["video", "roomJoin"]),
  # Narrow on purpose: a leaked visitor token must not create or administer rooms.
  can_create: get_in(claims, ["video", "roomCreate"]),
  can_admin: get_in(claims, ["video", "roomAdmin"])
}

2. Providers

The pipeline talks to STT, LLM and TTS through behaviours. Real ones call Deepgram, OpenAI, ElevenLabs. Here they are three small modules, so the demo proves the plumbing rather than someone's API key.

defmodule Demo.STT do
  @behaviour Livekit.Agents.STT
  alias Livekit.Agents.STT.SpeechEvent

  @impl true
  def transcribe(audio, _opts) do
    # Pretend longer audio means more was said.
    text = if byte_size(audio) > 1000, do: "what is the garden like", else: "hello"
    {:ok, %SpeechEvent{type: :final, text: text, confidence: 0.94, language: "en-GB"}}
  end

  @impl true
  def stream(_config), do: {:error, :not_implemented}
  @impl true
  def capabilities, do: %{streaming: false, interim_results: false, languages: ["en-GB"]}
  @impl true
  def validate_config(_config), do: :ok
end

defmodule Demo.LLM do
  @behaviour Livekit.Agents.LLM

  @impl true
  def chat(chat_context, opts) do
    instructions = Keyword.get(opts, :instructions, "")
    last = chat_context |> Livekit.Agents.ChatContext.messages() |> List.last()
    heard = if last, do: to_string(last.content), else: ""

    {:ok,
     %{
       role: :assistant,
       content: "[#{instructions}] You said: #{heard}. It is south-facing, about 60ft."
     }}
  end

  @impl true
  def stream(_ctx, _opts), do: {:error, :not_implemented}
  @impl true
  def capabilities,
    do: %{streaming: false, tool_calling: false, vision: false, max_context_tokens: 4096}

  @impl true
  def validate_config(_config), do: :ok
end

defmodule Demo.TTS do
  @behaviour Livekit.Agents.TTS

  @impl true
  # Real TTS returns encoded audio; the byte count is what matters here.
  def synthesize(text, _opts), do: {:ok, :binary.copy(<<0>>, byte_size(text) * 16)}
  @impl true
  def stream(_config), do: {:error, :not_implemented}
  @impl true
  def capabilities, do: %{streaming: false, voices: ["demo"], formats: [:pcm16]}
  @impl true
  def validate_config(_config), do: :ok
end

:providers_defined

3. A pipeline turn, end to end

Pipeline is a GenServer. It takes {module, config} tuples for all three providers and delivers synthesised audio back to :subscriber as {:pipeline_audio, frame}.

alias Livekit.Agents.{Pipeline, AudioFrame}

{:ok, pipeline} =
  Pipeline.start_link(%Pipeline.Config{
    stt: {Demo.STT, %{}},
    llm: {Demo.LLM, %{}},
    tts: {Demo.TTS, %{}},
    llm_opts: [instructions: "You are a UK estate agent."],
    subscriber: self(),
    # Leave vad_threshold at its default. Setting it to 0.0 classifies
    # SILENCE as speech, so the turn never closes and no reply is ever
    # synthesised — the pipeline looks broken when it is doing as it was told.
    silence_ms: 100
  })

Process.alive?(pipeline)

Push ~100ms of loud audio, then silence to close the turn:

speech = AudioFrame.new(:crypto.strong_rand_bytes(4800), sample_rate: 48_000)
silence = AudioFrame.new(:binary.copy(<<0>>, 4800), sample_rate: 48_000)

Pipeline.push_frame(pipeline, speech)
Process.sleep(50)
for _ <- 1..6, do: Pipeline.push_frame(pipeline, silence)

# The reply comes back asynchronously — this is the whole point of the design.
reply =
  receive do
    {:pipeline_audio, %AudioFrame{} = frame} -> {:spoke, byte_size(frame.data)}
  after
    3000 -> :no_audio_within_3s
  end

The chat context should now hold both sides of that exchange:

pipeline
|> Pipeline.get_chat_context()
|> Livekit.Agents.ChatContext.messages()
|> Enum.map(&{&1.role, to_string(&1.content) |> String.slice(0, 60)})
Pipeline.get_metrics(pipeline)
Pipeline.stop(pipeline)

4. VoiceAgent

The higher-level wrapper. This is what could not start at all before: it built its pipeline through a builder API that never existed.

alias Livekit.Agents.VoiceAgent

{:ok, agent} =
  VoiceAgent.start_link(%VoiceAgent.Config{
    name: "Kerbside",
    instructions: "You answer questions about the house you are outside.",
    stt: {Demo.STT, %{}},
    llm: {Demo.LLM, %{}},
    tts: {Demo.TTS, %{}}
  })

Process.alive?(agent)
VoiceAgent.process_audio_frame(agent, speech)
Process.sleep(50)
for _ <- 1..6, do: VoiceAgent.process_audio_frame(agent, silence)
Process.sleep(500)

VoiceAgent.get_metrics(agent)

An agent with no providers is still valid — it just has no pipeline. This is the documented "instructions only" configuration, and it used to crash.

{:ok, bare} = VoiceAgent.start_link(%VoiceAgent.Config{instructions: "Say hello"})
result = {Process.alive?(bare), VoiceAgent.get_metrics(bare)}
GenServer.stop(bare)
result
GenServer.stop(agent)

5. What is NOT wired up

Being able to say this precisely is the point of the exercise.

%{
  rust_nif_built: Livekit.WebRTC.Native.native?(),
  # The SDK ships no supervision tree, so nothing starts the event bus
  # registry. Subscribing to agent events needs the host app to start it.
  event_bus_registry_running: is_pid(Process.whereis(Livekit.Agents.EventBus.Registry)),
  note:
    "Publishing audio into a real room needs the NIF plus a LiveKit server. " <>
      "Everything above runs without either."
}