Skip to main content

relaymessenger-livekit

relaymessenger-livekit connects Relay Calls to Python LiveKit Agents. It is the Python twin of the npm package @relaymessenger/livekit. Relay stays responsible for the Call resource and signaling; the package owns the WebRTC peer (aiortc) and hands audio and video to LiveKit as rtc.AudioFrame and rtc.VideoFrame. Application code does not handle Cloudflare, SDP, ICE, or SFU credentials. It uses only Relay's public API: the Call room WebSocket (GET /v1/calls/{callId}/room) with the agent's token.

pip install relaymessenger-livekit

Python 3.10 or newer. livekit-agents, livekit and relaymessenger[calls], the Relay SDK with its call core, are dependencies. Like LiveKit's own room output, the agent's audio is held until the person is receiving it, so a greeting that starts early is heard from its first word.

Answer a Relay Call

Agents receive call.created through the normal Relay Webhook or acknowledged Agent WebSocket. Joining that Call's authenticated room answers it; there is no agent call URL or separate accept endpoint.

import os

from livekit.agents import Agent, AgentSession
from livekit.plugins import google
from relaymessenger_livekit import RelayLiveKitCall


async def answer_call(call_id: str) -> RelayLiveKitCall:
    call = await RelayLiveKitCall.connect(
        api_key=os.environ["RELAY_AGENT_TOKEN"],
        call_id=call_id,
    )
    session = AgentSession(llm=google.realtime.RealtimeModel())
    call.attach(session)

    @call.transport.on("ended")
    def _ended(_frame: dict) -> None:
        asyncio.ensure_future(call.aclose())

    await call.wait_for_peer_audio(15_000)
    await session.start(Agent(instructions="You are a helpful voice agent."))
    return call

RelayLiveKitCall.connect() returns once the WebRTC media peer has reached connected. RelayAudioInput hands the AgentSession the remote participant's audio as 24 kHz mono PCM16 frames, the format of LiveKit's own room input.

RelayAudioOutput has the semantics of LiveKit's own _ParticipantAudioOutput. capture_frame() hands each frame to the transport and returns at once, so the AgentSession may push a whole reply faster than real time; the 20 ms pacer paces the wire. flush() closes the segment and reports playback_finished only after the transport has drained. clear_buffer() drops audio that has not reached the wire and reports the segment as interrupted at the position that actually played.

On the transport, await write_audio() returns once its 10 ms slices are queued, queued_audio_ms() is what has not left yet, and await wait_for_playout() returns when the queue is empty (early on clear_audio()).

Use set_muted(True) to publish participant mute state, end() to end the Relay Call, and await aclose() for local cleanup. If the signaling connection is replaced, await call.transport.reconnect() keeps the existing media peer and replays the same audio publication so Relay can return its cached answer.

Live video: the agent sees the camera

call.attach(session) also sets session.input.video to a RelayVideoInput that yields the caller's camera as rtc.VideoFrame (I420). The AgentSession's built-in video sampler and its forwarding to a realtime model work unchanged, so Gemini Live sees what the caller shows:

from livekit.agents import Agent, AgentSession
from livekit.plugins import google
from relaymessenger_livekit import RelayLiveKitCall

call = await RelayLiveKitCall.connect(api_key=token, call_id=call_id)
session = AgentSession(llm=google.realtime.RealtimeModel())
call.attach(session)  # audio in, audio out, and the camera
await call.wait_for_peer_audio(15_000)
await session.start(Agent(instructions="Describe what the caller shows you."))

Send a video feed with LiveKit's names (VideoSource.capture_frame, LocalVideoTrack.create_video_track):

from livekit import rtc
from relaymessenger_livekit import LocalVideoTrack, VideoSource

source = VideoSource(1280, 720)
track = LocalVideoTrack.create_video_track("camera", source)
await call.transport.publish_track(track)

# RGBA, BGRA, ARGB, ABGR, RGB24 or I420 bytes, tightly packed, 30 frames a second.
source.capture_frame(rtc.VideoFrame(1280, 720, rtc.VideoBufferType.RGBA, rgba))

# Camera off, then on again; the track stays negotiated.
await call.transport.unpublish_track(track)
await call.transport.publish_track(track)

Read the other participant's video without an AgentSession:

from relaymessenger_livekit import VideoStream


@call.transport.on("track_subscribed")
def _on_track(track) -> None:
    async def read() -> None:
        async for event in VideoStream(track, capacity=2):
            frame = event.frame  # rtc.VideoFrame, I420

    asyncio.ensure_future(read())


@call.transport.on("remote_video")
def _on_camera(on: bool) -> None:
    ...  # the other participant's camera started or stopped sending

Video is H.264 constrained baseline, the profile Cloudflare's SFU accepts, encoded by aiortc with libx264, up to 1920x1080 at 30 fps; received H.264 and VP8 are decoded. Without a VideoEncoding, each frame size gets LiveKit's camera preset, for example 3 Mbps at 30 fps for 1920x1080. Frames arrive upright as sent; aiortc does not negotiate the video-orientation extension, so rotation is always 0. call.transport.video_stats() reports frames captured, sent, decoded and dropped.

examples/gemini_live_video_agent.py is a whole agent: it answers the next Call with Gemini Live, which sees the caller's camera, and sends a camera feed back.

uv run --with 'livekit-agents[google]' examples/gemini_live_video_agent.py

Drive a Rive file

Instead of sending video, the agent can have the phone draw its own Rive file (the rive on its profile). RelayRive has the shape of LiveKit's avatar plugins, with the Relay call in place of the room:

from livekit.agents import AgentSession
from relaymessenger_livekit import RelayLiveKitCall, RelayRive

session = AgentSession(stt=..., llm=..., tts=..., use_tts_aligned_transcript=True)
call = await RelayLiveKitCall.connect(api_key=token, call_id=call_id)
call.attach(session)
avatar = RelayRive()
await avatar.start(session, call)
avatar.rive.on("trigger", lambda name: print("the person fired", name))

Each reply sets the View Model's speaking true when its audio starts and false when it ends. With use_tts_aligned_transcript=True and a TTS that reports word times (ElevenLabs, Cartesia), each word also becomes viseme numbers (Preston Blair's ten mouths, 0 rest to 9 WQ) timed against the agent's audio. Rename them with viseme_property= and speaking_property=, or pass None. avatar.rive sets any other value, fires triggers and switches files.

ICE servers, TURN, restarts and diagnostics

By default the peer uses the servers the Call room sends after it joins: Cloudflare's STUN server and TURN credentials that Relay mints for the call. An agent behind a NAT that blocks direct paths connects through TURN with no setup. Each restart uses the room's latest servers. A room that sends none falls back to Cloudflare's STUN server (stun:stun.cloudflare.com:3478), as Cloudflare's own Realtime echo example does.

aiortc uses only the first STUN URL and the first TURN URL, so the room's list is ordered to put turn:...:3478?transport=udp first. A network that blocks all outbound UDP therefore needs your own turn:...?transport=tcp server first in ice_servers. aiortc also gathers every candidate before the offer can leave, and a TURN server adds up to 5 seconds to that wait. On an open network, pass STUN only to skip it:

from relaymessenger_livekit import RelayIceServer

call = await RelayLiveKitCall.connect(
    api_key=token,
    call_id=call_id,
    ice_servers=[RelayIceServer(urls="stun:stun.cloudflare.com:3478")],
)

Your ice_servers replaces the room's; pass your own TURN server the same way, with username and credential. aiortc has no option to force every path through TURN. ice_servers may also be a function of the restart count (sync or async); it is called before every peer connection, so it can mint fresh TURN credentials for each restart. The room's servers are on room.ice_servers and its ice_servers event.

connect() has no overall deadline: when an SFU session is not connected within session_connect_timeout_ms (5 seconds by default) of its answer, becomes failed, or stays disconnected for 7 seconds, the transport closes that peer, waits 250 ms (x1.1 per further attempt, at most 10 s), and publishes from a new peer on a new session, for as long as the Call is ringing or in progress. Outgoing audio and the published camera carry over. Each replacement emits restarted with the reason and the replaced session's summary. The room socket reconnects on its own after any close nobody asked for (3 s, then x1.3, at most 10 s) without touching media, and sends a heartbeat every 5 s.

call.diagnostics() returns the facts for logs, with a one-line summary, for example local: host 2, srflx 1, relay 0; remote: udp 1473; states: …, connected 1.4s; in: 1234 rtp, 1234 frames, first 0.9s last 41.2s, 250/5s, 50.0/s; out: 2600 frames, 1300 opus, 1300 rtp, silence 700, first 1.1s last 41.0s, 250/5s, 50.0/s, queue 0, pacer alive; room: 3 roomState, 1 offer, 1 open: packets both ways and per second, silence packets and restarts. It has no winning candidate pair: aiortc's public API does not expose it.

Like a live microphone, the published track sends one Opus packet every 20 ms from the moment media connects until the transport closes, paced by the monotonic clock: a late wake sends every packet due by then, so the wire carries exactly 50 packets a second; a stall longer than 200 ms restarts the clock instead of bursting. The track carries the caller's audio when some is queued, Opus silence otherwise, because Cloudflare's SFU will not let the other side pull a track that has carried no RTP. Silence never counts toward queued_audio_ms() or wait_for_playout(). on_warning is called once per call, with the summary, when outbound audio is queued but no packet has left for 2 s while media is connected.

Provider-neutral transport

RelayCallTransport is the WebRTC/PCM layer without LiveKit Agents:

from relaymessenger_livekit import RelayAudioFrame, RelayCallTransport, RelayInboundAudioFormat

transport = RelayCallTransport(
    api_key=token,
    call_id=call_id,
    inbound_audio=RelayInboundAudioFormat(sample_rate=48_000, channel_count=1),
)


@transport.on("audio")
def _on_audio(frame: RelayAudioFrame) -> None:
    ...  # frame.samples: interleaved int16 numpy array


await transport.connect()
await transport.write_audio(RelayAudioFrame(samples=pcm, sample_rate=48_000, channel_count=1))
await transport.wait_for_playout()

Metadata

Release files for relaymessenger-livekit 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for relaymessenger-livekit 0.1.0
File Size Uploaded
relaymessenger_livekit-0.1.0.tar.gz 260.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for relaymessenger-livekit 0.1.0
File Interpreter ABI Platform
relaymessenger_livekit-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 277.0 kB

Release files / relaymessenger_livekit-0.1.0.tar.gz

Download URL relaymessenger_livekit-0.1.0.tar.gz
Size 260.6 kB
Tags Source
SHA-256 checksum
How to use checksums
b072fcb1a5a5093a95ddf2bea2f510871bd54166c883af716044dd96286c092c
BLAKE2b-256 checksum
How to use checksums
cbc89508d5b13bf72b66f6a832dab066ff24065761f84302141f185491a262d7
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release files / relaymessenger_livekit-0.1.0-py3-none-any.whl

Download URL relaymessenger_livekit-0.1.0-py3-none-any.whl
Size 16.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
e3a961726817c38334d690da951da7b05bdd02161f4ced3c03a146863f810d70
BLAKE2b-256 checksum
How to use checksums
f91845ccc74e330c4d5b5f753dd78d16df0de32977292302047811e3b2e04678
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.13.14

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page