Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions examples/avatar_agents/runway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# LiveKit Runway Avatar Agent

This example demonstrates how to create an animated avatar using [Runway Characters](https://dev.runwayml.com/).

## Usage

- Update the environment:

```bash
# Runway Config
export RUNWAYML_API_SECRET="..."
export RUNWAY_AVATAR_PRESET_ID="..." # or RUNWAY_AVATAR_ID for a custom avatar

# Google config (or other models, tts, stt)
export GOOGLE_API_KEY="..."

# LiveKit config
export LIVEKIT_API_KEY="..."
export LIVEKIT_API_SECRET="..."
export LIVEKIT_URL="..."
```

- Start the agent worker:

```bash
python examples/avatar_agents/runway/agent_worker.py dev
```

38 changes: 38 additions & 0 deletions examples/avatar_agents/runway/agent_worker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging
import os

from dotenv import load_dotenv

from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
from livekit.plugins import google, runway

logger = logging.getLogger("runway-avatar-example")
logger.setLevel(logging.INFO)

load_dotenv()

server = AgentServer()


@server.rtc_session()
async def entrypoint(ctx: JobContext):
session = AgentSession(
llm=google.realtime.RealtimeModel(voice="kore"),
resume_false_interruption=False,
)

avatar_id = os.getenv("RUNWAY_AVATAR_ID")
preset_id = os.getenv("RUNWAY_AVATAR_PRESET_ID")
runway_avatar = runway.AvatarSession(avatar_id=avatar_id, preset_id=preset_id)
await runway_avatar.start(session, room=ctx.room)

await session.start(
agent=Agent(instructions="Talk to me!"),
room=ctx.room,
)

session.generate_reply(instructions="say hello to the user")


if __name__ == "__main__":
cli.run_app(server)
1 change: 1 addition & 0 deletions livekit-agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ nvidia = ["livekit-plugins-nvidia>=1.5.2"]
openai = ["livekit-plugins-openai>=1.5.2"]
resemble = ["livekit-plugins-resemble>=1.5.2"]
rime = ["livekit-plugins-rime>=1.5.2"]
runway = ["livekit-plugins-runway>=1.5.2"]
rtzr = ["livekit-plugins-rtzr>=1.5.2"]
sarvam = ["livekit-plugins-sarvam>=1.5.2"]
silero = ["livekit-plugins-silero>=1.5.2"]
Expand Down
62 changes: 62 additions & 0 deletions livekit-plugins/livekit-plugins-runway/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# livekit-plugins-runway

[LiveKit Agents](https://docs.livekit.io/agents/) plugin for [Runway Characters](https://dev.runwayml.com/) avatar integration.

Your LiveKit agent owns the full conversational AI pipeline (STT, LLM, TTS). Runway provides the visual layer — audio in, avatar video out.

## Installation

```bash
pip install livekit-plugins-runway
```

## Usage

```python
from livekit.agents import AgentSession, Agent, RoomOutputOptions
from livekit.plugins import runway

async def entrypoint(ctx):
session = AgentSession()

avatar = runway.AvatarSession(
avatar_id="your-custom-avatar-id",
# api_key defaults to RUNWAYML_API_SECRET env var
)
await avatar.start(session, room=ctx.room)

await session.start(
agent=Agent(instructions="Talk to me!"),
room=ctx.room,
room_output_options=RoomOutputOptions(audio_enabled=False),
)
```

### Using a preset avatar

```python
avatar = runway.AvatarSession(
preset_id="runway-preset-slug",
)
```

### With a session duration limit

```python
avatar = runway.AvatarSession(
avatar_id="your-custom-avatar-id",
max_duration=300,
)
```

## Configuration

| Parameter | Env var | Description |
|-----------|---------|-------------|
| `api_key` | `RUNWAYML_API_SECRET` | Runway API key |
| `api_url` | `RUNWAYML_BASE_URL` | API base URL (default: `https://api.dev.runwayml.com`) |
| `avatar_id` | — | Custom avatar ID (mutually exclusive with `preset_id`) |
| `preset_id` | — | Preset avatar slug (mutually exclusive with `avatar_id`) |
| `max_duration` | — | Maximum session duration in seconds |

LiveKit credentials (`LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`) are read from environment variables or can be passed to `avatar.start()`.
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2025 LiveKit, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Runway Characters avatar plugin for LiveKit Agents"""

from .avatar import AvatarSession, RunwayException
from .version import __version__

__all__ = [
"RunwayException",
"AvatarSession",
"__version__",
]

from livekit.agents import Plugin

from .log import logger


class RunwayPlugin(Plugin):
def __init__(self) -> None:
super().__init__(__name__, __version__, __package__, logger)


Plugin.register_plugin(RunwayPlugin())
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
from __future__ import annotations

import asyncio
import os

import aiohttp

from livekit import api, rtc
from livekit.agents import (
DEFAULT_API_CONNECT_OPTIONS,
NOT_GIVEN,
AgentSession,
APIConnectionError,
APIConnectOptions,
APIStatusError,
NotGivenOr,
get_job_context,
utils,
)
from livekit.agents.voice.avatar import DataStreamAudioOutput
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF

from .log import logger

DEFAULT_API_URL = "https://api.dev.runwayml.com"
API_VERSION = "2024-11-06"
SAMPLE_RATE = 16000
_AVATAR_AGENT_IDENTITY = "runway-avatar-agent"
_AVATAR_AGENT_NAME = "runway-avatar-agent"


class RunwayException(Exception):
"""Exception for Runway errors"""


class AvatarSession:
"""A Runway Characters avatar session.

Creates a realtime session backed by Runway's avatar inference pipeline.
The customer's LiveKit agent owns the conversational AI stack (STT, LLM, TTS);
Runway provides the visual layer — audio in, avatar video out.
"""

def __init__(
self,
*,
avatar_id: NotGivenOr[str | None] = NOT_GIVEN,
preset_id: NotGivenOr[str | None] = NOT_GIVEN,
max_duration: NotGivenOr[int] = NOT_GIVEN,
api_url: NotGivenOr[str] = NOT_GIVEN,
api_key: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
) -> None:
if not avatar_id and not preset_id:
raise RunwayException("Either avatar_id or preset_id must be provided")
if avatar_id and preset_id:
raise RunwayException("Provide avatar_id or preset_id, not both")

if avatar_id:
self._avatar: dict[str, str] = {"type": "custom", "avatarId": str(avatar_id)}
else:
self._avatar = {"type": "runway-preset", "presetId": str(preset_id)}
self._max_duration = max_duration

self._api_url = api_url or os.getenv("RUNWAYML_BASE_URL", DEFAULT_API_URL)
self._api_key = api_key or os.getenv("RUNWAYML_API_SECRET")
if self._api_key is None:
raise RunwayException(
"api_key must be set either by passing it to AvatarSession or "
"by setting the RUNWAYML_API_SECRET environment variable"
)

self._avatar_participant_identity = avatar_participant_identity or _AVATAR_AGENT_IDENTITY
self._avatar_participant_name = avatar_participant_name or _AVATAR_AGENT_NAME
self._http_session: aiohttp.ClientSession | None = None
self._conn_options = conn_options

def _ensure_http_session(self) -> aiohttp.ClientSession:
if self._http_session is None:
self._http_session = utils.http_context.http_session()
return self._http_session

async def start(
self,
agent_session: AgentSession,
room: rtc.Room,
*,
livekit_url: NotGivenOr[str] = NOT_GIVEN,
livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
) -> None:
livekit_url = livekit_url or (os.getenv("LIVEKIT_URL") or NOT_GIVEN)
livekit_api_key = livekit_api_key or (os.getenv("LIVEKIT_API_KEY") or NOT_GIVEN)
livekit_api_secret = livekit_api_secret or (os.getenv("LIVEKIT_API_SECRET") or NOT_GIVEN)
if not livekit_url or not livekit_api_key or not livekit_api_secret:
raise RunwayException(
"livekit_url, livekit_api_key, and livekit_api_secret must be set "
"by arguments or environment variables"
)

job_ctx = get_job_context()
self._local_participant_identity = job_ctx.local_participant_identity

livekit_token = (
api.AccessToken(api_key=livekit_api_key, api_secret=livekit_api_secret)
.with_kind("agent")
.with_identity(self._avatar_participant_identity)
.with_name(self._avatar_participant_name)
.with_grants(api.VideoGrants(room_join=True, room=room.name))
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: self._local_participant_identity})
.to_jwt()
)

logger.debug("starting Runway avatar session")
await self._create_session(livekit_url, livekit_token, room.name)

agent_session.output.audio = DataStreamAudioOutput(
room=room,
destination_identity=self._avatar_participant_identity,
wait_remote_track=rtc.TrackKind.KIND_VIDEO,
sample_rate=SAMPLE_RATE,
)

async def _create_session(self, livekit_url: str, livekit_token: str, room_name: str) -> None:
assert self._api_key is not None
assert isinstance(self._api_url, str)

body: dict[str, object] = {
"model": "gwm1_avatars",
"avatar": self._avatar,
"livekit": {
"url": livekit_url,
"token": livekit_token,
"roomName": room_name,
"agentIdentity": self._local_participant_identity,
},
}

if self._max_duration:
body["maxDuration"] = self._max_duration
Comment on lines +141 to +142
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Truthiness check on max_duration silently drops max_duration=0

On line 141, if self._max_duration: uses a truthiness check to decide whether to include maxDuration in the API request body. Since self._max_duration is of type NotGivenOr[int], this conflates NOT_GIVEN (falsy) with the integer 0 (also falsy). If a user passes max_duration=0, it will be silently omitted from the request. The established codebase pattern for checking NotGivenOr values (especially non-string types) is to use is_given() — see e.g. livekit-plugins/livekit-plugins-lemonslice/livekit/plugins/lemonslice/api.py:119 which uses if utils.is_given(idle_timeout): for the same kind of NotGivenOr[int] parameter, and livekit-plugins/livekit-plugins-anthropic/livekit/plugins/anthropic/llm.py:157 which uses if is_given(self._opts.temperature): for NotGivenOr[float].

Suggested change
if self._max_duration:
body["maxDuration"] = self._max_duration
if utils.is_given(self._max_duration):
body["maxDuration"] = self._max_duration
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


for attempt in range(self._conn_options.max_retry):
try:
async with self._ensure_http_session().post(
f"{self._api_url}/v1/realtime_sessions",
headers={
"Authorization": f"Bearer {self._api_key}",
"X-Runway-Version": API_VERSION,
},
json=body,
timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
) as response:
if not response.ok:
text = await response.text()
raise APIStatusError(
"Runway API returned an error",
status_code=response.status,
body=text,
)
return

except Exception as error:
if isinstance(error, APIStatusError):
raise

if isinstance(error, APIConnectionError):
logger.warning(
"failed to call Runway avatar API",
extra={"error": str(error)},
)
else:
logger.exception("failed to call Runway avatar API")

if attempt < self._conn_options.max_retry - 1:
await asyncio.sleep(self._conn_options.retry_interval)

raise APIConnectionError("Failed to start Runway Avatar Session after all retries")
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import logging

logger = logging.getLogger("livekit.plugins.runway")
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
__version__ = "1.5.2"
Loading