Skip to content

Commit 214882f

Browse files
committed
feat: add Runway Characters avatar plugin
Add livekit-plugins-runway for Runway Characters avatar integration. The LiveKit agent owns the conversational AI stack (STT, LLM, TTS); Runway provides the visual layer — audio in, avatar video out.
1 parent 3a2dc58 commit 214882f

10 files changed

Lines changed: 2926 additions & 2523 deletions

File tree

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
# LiveKit Runway Avatar Agent
2+
3+
This example demonstrates how to create an animated avatar using [Runway Characters](https://dev.runwayml.com/).
4+
5+
## Usage
6+
7+
* Update the environment:
8+
9+
```bash
10+
# Runway Config
11+
export RUNWAYML_API_SECRET="..."
12+
export RUNWAY_AVATAR_PRESET_ID="..." # or RUNWAY_AVATAR_ID for a custom avatar
13+
14+
# Google config (or other models, tts, stt)
15+
export GOOGLE_API_KEY="..."
16+
17+
# LiveKit config
18+
export LIVEKIT_API_KEY="..."
19+
export LIVEKIT_API_SECRET="..."
20+
export LIVEKIT_URL="..."
21+
```
22+
23+
* Start the agent worker:
24+
25+
```bash
26+
python examples/avatar_agents/runway/agent_worker.py dev
27+
```
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import logging
2+
import os
3+
4+
from dotenv import load_dotenv
5+
6+
from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli
7+
from livekit.plugins import google, runway
8+
9+
logger = logging.getLogger("runway-avatar-example")
10+
logger.setLevel(logging.INFO)
11+
12+
load_dotenv()
13+
14+
server = AgentServer()
15+
16+
17+
@server.rtc_session()
18+
async def entrypoint(ctx: JobContext):
19+
session = AgentSession(
20+
llm=google.realtime.RealtimeModel(voice="kore"),
21+
resume_false_interruption=False,
22+
)
23+
24+
avatar_id = os.getenv("RUNWAY_AVATAR_ID")
25+
preset_id = os.getenv("RUNWAY_AVATAR_PRESET_ID")
26+
runway_avatar = runway.AvatarSession(avatar_id=avatar_id, preset_id=preset_id)
27+
await runway_avatar.start(session, room=ctx.room)
28+
29+
await session.start(
30+
agent=Agent(instructions="Talk to me!"),
31+
room=ctx.room,
32+
)
33+
34+
session.generate_reply(instructions="say hello to the user")
35+
36+
37+
if __name__ == "__main__":
38+
cli.run_app(server)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
livekit-plugins-runway
2+
livekit-plugins-google
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# livekit-plugins-runway
2+
3+
[LiveKit Agents](https://docs.livekit.io/agents/) plugin for [Runway Characters](https://dev.runwayml.com/) avatar integration.
4+
5+
Your LiveKit agent owns the full conversational AI pipeline (STT, LLM, TTS). Runway provides the visual layer — audio in, avatar video out.
6+
7+
## Installation
8+
9+
```bash
10+
pip install livekit-plugins-runway
11+
```
12+
13+
## Usage
14+
15+
```python
16+
from livekit.agents import AgentSession, Agent, RoomOutputOptions
17+
from livekit.plugins import runway
18+
19+
async def entrypoint(ctx):
20+
session = AgentSession()
21+
22+
avatar = runway.AvatarSession(
23+
avatar_id="your-custom-avatar-id",
24+
# api_key defaults to RUNWAYML_API_SECRET env var
25+
)
26+
await avatar.start(session, room=ctx.room)
27+
28+
await session.start(
29+
agent=Agent(instructions="Talk to me!"),
30+
room=ctx.room,
31+
room_output_options=RoomOutputOptions(audio_enabled=False),
32+
)
33+
```
34+
35+
### Using a preset avatar
36+
37+
```python
38+
avatar = runway.AvatarSession(
39+
preset_id="runway-preset-slug",
40+
)
41+
```
42+
43+
### With a session duration limit
44+
45+
```python
46+
avatar = runway.AvatarSession(
47+
avatar_id="your-custom-avatar-id",
48+
max_duration=300,
49+
)
50+
```
51+
52+
## Configuration
53+
54+
| Parameter | Env var | Description |
55+
|-----------|---------|-------------|
56+
| `api_key` | `RUNWAYML_API_SECRET` | Runway API key |
57+
| `api_url` | `RUNWAYML_BASE_URL` | API base URL (default: `https://api.dev.runwayml.com`) |
58+
| `avatar_id` || Custom avatar ID (mutually exclusive with `preset_id`) |
59+
| `preset_id` || Preset avatar slug (mutually exclusive with `avatar_id`) |
60+
| `max_duration` || Maximum session duration in seconds |
61+
62+
LiveKit credentials (`LIVEKIT_URL`, `LIVEKIT_API_KEY`, `LIVEKIT_API_SECRET`) are read from environment variables or can be passed to `avatar.start()`.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Copyright 2025 LiveKit, Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Runway Characters avatar plugin for LiveKit Agents"""
16+
17+
from .avatar import AvatarSession, RunwayException
18+
from .version import __version__
19+
20+
__all__ = [
21+
"RunwayException",
22+
"AvatarSession",
23+
"__version__",
24+
]
25+
26+
from livekit.agents import Plugin
27+
28+
from .log import logger
29+
30+
31+
class RunwayPlugin(Plugin):
32+
def __init__(self) -> None:
33+
super().__init__(__name__, __version__, __package__, logger)
34+
35+
36+
Plugin.register_plugin(RunwayPlugin())
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
from __future__ import annotations
2+
3+
import asyncio
4+
import os
5+
6+
import aiohttp
7+
8+
from livekit import api, rtc
9+
from livekit.agents import (
10+
DEFAULT_API_CONNECT_OPTIONS,
11+
NOT_GIVEN,
12+
AgentSession,
13+
APIConnectionError,
14+
APIConnectOptions,
15+
APIStatusError,
16+
NotGivenOr,
17+
get_job_context,
18+
utils,
19+
)
20+
from livekit.agents.voice.avatar import DataStreamAudioOutput
21+
from livekit.agents.voice.room_io import ATTRIBUTE_PUBLISH_ON_BEHALF
22+
23+
from .log import logger
24+
25+
DEFAULT_API_URL = "https://api.dev.runwayml.com"
26+
API_VERSION = "2024-11-06"
27+
SAMPLE_RATE = 16000
28+
_AVATAR_AGENT_IDENTITY = "runway-avatar-agent"
29+
_AVATAR_AGENT_NAME = "runway-avatar-agent"
30+
31+
32+
class RunwayException(Exception):
33+
"""Exception for Runway errors"""
34+
35+
36+
class AvatarSession:
37+
"""A Runway Characters avatar session.
38+
39+
Creates a realtime session backed by Runway's avatar inference pipeline.
40+
The customer's LiveKit agent owns the conversational AI stack (STT, LLM, TTS);
41+
Runway provides the visual layer — audio in, avatar video out.
42+
"""
43+
44+
def __init__(
45+
self,
46+
*,
47+
avatar_id: NotGivenOr[str | None] = NOT_GIVEN,
48+
preset_id: NotGivenOr[str | None] = NOT_GIVEN,
49+
max_duration: NotGivenOr[int] = NOT_GIVEN,
50+
api_url: NotGivenOr[str] = NOT_GIVEN,
51+
api_key: NotGivenOr[str] = NOT_GIVEN,
52+
avatar_participant_identity: NotGivenOr[str] = NOT_GIVEN,
53+
avatar_participant_name: NotGivenOr[str] = NOT_GIVEN,
54+
conn_options: APIConnectOptions = DEFAULT_API_CONNECT_OPTIONS,
55+
) -> None:
56+
if not avatar_id and not preset_id:
57+
raise RunwayException("Either avatar_id or preset_id must be provided")
58+
if avatar_id and preset_id:
59+
raise RunwayException("Provide avatar_id or preset_id, not both")
60+
61+
self._avatar_id = avatar_id
62+
self._preset_id = preset_id
63+
self._max_duration = max_duration
64+
65+
self._api_url = api_url or os.getenv("RUNWAYML_BASE_URL", DEFAULT_API_URL)
66+
self._api_key = api_key or os.getenv("RUNWAYML_API_SECRET")
67+
if self._api_key is None:
68+
raise RunwayException(
69+
"api_key must be set either by passing it to AvatarSession or "
70+
"by setting the RUNWAYML_API_SECRET environment variable"
71+
)
72+
73+
self._avatar_participant_identity = avatar_participant_identity or _AVATAR_AGENT_IDENTITY
74+
self._avatar_participant_name = avatar_participant_name or _AVATAR_AGENT_NAME
75+
self._http_session: aiohttp.ClientSession | None = None
76+
self._conn_options = conn_options
77+
78+
def _ensure_http_session(self) -> aiohttp.ClientSession:
79+
if self._http_session is None:
80+
self._http_session = utils.http_context.http_session()
81+
return self._http_session
82+
83+
async def start(
84+
self,
85+
agent_session: AgentSession,
86+
room: rtc.Room,
87+
*,
88+
livekit_url: NotGivenOr[str] = NOT_GIVEN,
89+
livekit_api_key: NotGivenOr[str] = NOT_GIVEN,
90+
livekit_api_secret: NotGivenOr[str] = NOT_GIVEN,
91+
) -> None:
92+
livekit_url = livekit_url or (os.getenv("LIVEKIT_URL") or NOT_GIVEN)
93+
livekit_api_key = livekit_api_key or (os.getenv("LIVEKIT_API_KEY") or NOT_GIVEN)
94+
livekit_api_secret = livekit_api_secret or (os.getenv("LIVEKIT_API_SECRET") or NOT_GIVEN)
95+
if not livekit_url or not livekit_api_key or not livekit_api_secret:
96+
raise RunwayException(
97+
"livekit_url, livekit_api_key, and livekit_api_secret must be set "
98+
"by arguments or environment variables"
99+
)
100+
101+
job_ctx = get_job_context()
102+
local_participant_identity = job_ctx.local_participant_identity
103+
104+
livekit_token = (
105+
api.AccessToken(api_key=livekit_api_key, api_secret=livekit_api_secret)
106+
.with_kind("agent")
107+
.with_identity(self._avatar_participant_identity)
108+
.with_name(self._avatar_participant_name)
109+
.with_grants(api.VideoGrants(room_join=True, room=room.name))
110+
.with_attributes({ATTRIBUTE_PUBLISH_ON_BEHALF: local_participant_identity})
111+
.to_jwt()
112+
)
113+
114+
logger.debug("starting Runway avatar session")
115+
await self._create_session(livekit_url, livekit_token, room.name)
116+
117+
agent_session.output.audio = DataStreamAudioOutput(
118+
room=room,
119+
destination_identity=self._avatar_participant_identity,
120+
wait_remote_track=rtc.TrackKind.KIND_VIDEO,
121+
sample_rate=SAMPLE_RATE,
122+
)
123+
124+
async def _create_session(self, livekit_url: str, livekit_token: str, room_name: str) -> None:
125+
assert self._api_key is not None
126+
assert isinstance(self._api_url, str)
127+
128+
if self._avatar_id:
129+
avatar = {"type": "custom", "avatarId": self._avatar_id}
130+
else:
131+
avatar = {"type": "runway-preset", "presetId": self._preset_id}
132+
133+
body: dict = {
134+
"model": "gwm1_avatars",
135+
"avatar": avatar,
136+
"livekit": {
137+
"url": livekit_url,
138+
"token": livekit_token,
139+
"roomName": room_name,
140+
},
141+
}
142+
143+
if self._max_duration:
144+
body["maxDuration"] = self._max_duration
145+
146+
for attempt in range(self._conn_options.max_retry):
147+
try:
148+
async with self._ensure_http_session().post(
149+
f"{self._api_url}/v1/realtime_sessions",
150+
headers={
151+
"Authorization": f"Bearer {self._api_key}",
152+
"X-Runway-Version": API_VERSION,
153+
},
154+
json=body,
155+
timeout=aiohttp.ClientTimeout(sock_connect=self._conn_options.timeout),
156+
) as response:
157+
if not response.ok:
158+
text = await response.text()
159+
raise APIStatusError(
160+
"Runway API returned an error",
161+
status_code=response.status,
162+
body=text,
163+
)
164+
return
165+
166+
except Exception as error:
167+
if isinstance(error, APIStatusError):
168+
raise
169+
170+
if isinstance(error, APIConnectionError):
171+
logger.warning(
172+
"failed to call Runway avatar API",
173+
extra={"error": str(error)},
174+
)
175+
else:
176+
logger.exception("failed to call Runway avatar API")
177+
178+
if attempt < self._conn_options.max_retry - 1:
179+
await asyncio.sleep(self._conn_options.retry_interval)
180+
181+
raise APIConnectionError("Failed to start Runway Avatar Session after all retries")
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import logging
2+
3+
logger = logging.getLogger("livekit.plugins.runway")
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.1.0"

0 commit comments

Comments
 (0)