Skip to main content
from openrtc import AgentPool
from my_agents import RestaurantAgent, DentalAgent, SupportAgent

pool = AgentPool()                       # one worker, prewarm once
pool.add("restaurant", RestaurantAgent)  # standard livekit.agents.Agent subclasses
pool.add("dental", DentalAgent)
pool.add("support", SupportAgent)
pool.run()                               # N agents share one Silero VAD + turn detector

A thin multi-agent layer for LiveKit Agents. Register many standard livekit.agents.Agent subclasses on one AgentPool and host them in a single worker: shared prewarm (Silero VAD, turn detector) loads once instead of once per worker, and every incoming call still gets its own AgentSession. OpenRTC never introduces a base class and never sits between you and @function_tool, RunContext, on_enter, on_exit, or the *_node hooks. You change how many workers you run, not how you write an agent.

Why OpenRTC

The default one-worker-per-agent model in livekit-agents reloads the same stack (Python runtime, Silero VAD, turn detector) in every process. OpenRTC answers the questions an operator actually asks:

  • How many agents per box? One worker hosts every registered agent, each session an asyncio.Task over a shared JobProcess. The density benchmark clears 50+ concurrent sessions per worker under a 4 GB peak-RSS budget with headroom (see Density).
  • Do I rewrite my agents? No. Your Agent subclasses, tools, and provider objects are unchanged; you delete per-worker boilerplate (entrypoint, AgentSession wiring, cli.run_app) and register classes on one pool.
  • What does it cost in RAM? Prewarm loads once per worker, not once per agent, so you stop paying resident set for copies you do not need.
  • What if I need hard isolation? Pass isolation="process" for the one-subprocess-per-session model with independent crashes and livekit's per-session memory caps.

Features

Capability What it gives you
One worker, many agents Register N standard Agent subclasses on a single AgentPool; dispatch resolves one per call.
Shared prewarm Silero VAD and the turn detector load once per worker in coroutine mode, not once per agent.
Coroutine or process isolation Default coroutine runs each session as an asyncio.Task; process keeps one subprocess per session with hard isolation.
Metadata routing Ordered resolution across job metadata, room metadata, room-name prefix, then first-registered fallback.
Hot reload Edit an agent file and openrtc dev swaps live sessions on their next turn, no dropped calls. A bad save rolls back.
Session observers Structural-typed async start/end hooks for telemetry, isolated so a slow or raising observer never crashes the session.
JSONL metrics stream Append-only JSON Lines of pool snapshots and lifecycle events for tail -f, jq, or a log shipper.
LiveKit-shaped CLI start / dev / console / connect / download-files plus an OpenRTC-only list, with an optional Rich dashboard.
No base class Your Agent subclasses, @function_tool, RunContext, and node hooks stay exactly as written.

Full release history: docs/changelog.md.

Quick start

pip install openrtc            # or: uv add openrtc
pip install "openrtc[cli]"     # adds the openrtc CLI (rich + typer)

Requires Python 3.11 to 3.13 (>=3.11,<3.14; the transitive onnxruntime behind Silero and the turn detector has no 3.10 wheels). The base install pulls livekit-agents[openai,silero,turn-detector]>=1.5,<1.7 and watchfiles. Ships a PEP 561 py.typed marker. Set LIVEKIT_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET as for any LiveKit worker.

Explicit registration with add() when you want every agent named and configured in one place:

from livekit.agents import Agent
from livekit.plugins import openai
from openrtc import AgentPool


class RestaurantAgent(Agent):
    def __init__(self) -> None:
        super().__init__(instructions="You help callers make restaurant bookings.")


pool = AgentPool(default_llm=openai.responses.LLM(model="gpt-4.1-mini"))
pool.add(
    "restaurant",
    RestaurantAgent,
    stt=openai.STT(model="gpt-4o-mini-transcribe"),
    tts=openai.TTS(model="gpt-4o-mini-tts"),
    greeting="Welcome to reservations.",
)
pool.run()

One file per agent with discover() when you prefer a module per agent and optional @agent_config(...):

from pathlib import Path
from livekit.plugins import openai
from openrtc import AgentPool

pool = AgentPool(
    default_stt=openai.STT(model="gpt-4o-mini-transcribe"),
    default_llm=openai.responses.LLM(model="gpt-4.1-mini"),
    default_tts=openai.TTS(model="gpt-4o-mini-tts"),
)
pool.discover(Path("./agents"))
pool.run()
# agents/restaurant.py
from livekit.agents import Agent
from openrtc import agent_config


@agent_config(name="restaurant", greeting="Welcome to reservations.")
class RestaurantAgent(Agent):
    def __init__(self) -> None:
        super().__init__(instructions="You help callers make restaurant bookings.")

Without @agent_config the agent name defaults to the filename stem, and STT/LLM/TTS/greeting fall back to the pool defaults. Provider slots accept either instantiated plugin objects (openai.STT(...)) or shorthand strings ("openai/gpt-4o-mini-transcribe"), which the LiveKit runtime resolves at session construction. OpenRTC installs a sensible default turn_handling (multilingual turn detector with VAD interruption); override it per agent via session_kwargs. Define classes at module scope so spawn-based worker reload can import them. Depth: openrtc.mintlify.app.

Isolation modes

AgentPool(isolation=...) picks how each session runs inside the worker. Coroutine is the default; pass isolation="process" to opt into the one-subprocess-per-session model.

pool = AgentPool(
    isolation="coroutine",         # default
    max_concurrent_sessions=50,    # advisory backpressure (coroutine only)
    consecutive_failure_limit=5,   # supervisor threshold (coroutine only)
    drain_timeout=30,              # seconds to wait for in-flight sessions on SIGTERM
)
Aspect coroutine (default) process
Sessions per worker Many. One asyncio.Task per session over a shared JobProcess. One. Each session is its own subprocess via livekit-agents ProcPool.
Prewarm (VAD, turn detector) Loaded once per worker. Loaded once per session subprocess.
Crash isolation Cooperative: an unhandled exception is logged and the session marked FAILED; siblings continue. consecutive_failure_limit consecutive failures (default 5) schedule aclose() so the platform restarts the worker; one SUCCESS resets the counter. Hard: each subprocess crashes independently.
Per-session memory cap No cap knob (one process); livekit's job_memory_limit_mb has no effect here. Configure livekit-agents' own job_memory_limit_mb worker option.
Backpressure current_load() = active / max_concurrent_sessions, reported to LiveKit dispatch. Advisory only (unclamped, not a hard gate); sessions past the threshold still launch. livekit-agents default CPU-based load.
Dependency surface Uses livekit-agents private job internals; pinned to >=1.5,<1.7. An unsupported version fails import with a message pointing to isolation="process". Public, version-stable API.
When to pick High density on one host; cost-sensitive deployments. Regulatory hard isolation; per-session memory caps.

max_concurrent_sessions (50), consecutive_failure_limit (5), and drain_timeout (30) are validated as positive integers. On SIGTERM the worker drains: it stops accepting jobs and waits up to drain_timeout seconds for in-flight sessions before cancelling.

Density

From the stub-workload benchmark (tests/benchmarks/density.py, results in docs/benchmarks/density-v0.1.md; macOS arm64, Python 3.13, single worker). The gate: 50+ concurrent sessions per worker at 4 GB peak RSS with no errors. It passes with headroom.

Concurrent sessions Coroutine peak RSS Delta over idle Result
50 ~367 MB ~251 MB 50/50 ok
100 617 MB 502 MB ok
200 1073 MB 957 MB ok
500 1370 MB 1256 MB ok (8 GB cap)

Process mode instead loads the runtime plus models per session (~3 GB each, see docs/audit-2026-05-02.md), so the same 50 sessions would need ~150 GB; coroutine mode shares one process.

Throughput is the other half. N sessions share one event loop and one GIL, so tests/benchmarks/throughput.py drives real Silero VAD over synthetic 16 kHz PCM and measures steady-state loop p99: it stays well under a 100 ms budget to 100 sessions with flat RSS. Read both as an on-loop-CPU ceiling, not a full-pipeline guarantee: the harness stubs the WebRTC/STT/LLM/TTS path and the ~250 to 400 MB model footprint. Measure on your own hardware before quoting a sessions-per-worker number.

Prove it on your machine (no LiveKit server, no API keys, no model download):

uv run python examples/density_demo.py                # 16 sessions
uv run python examples/density_demo.py --sessions 50  # the gap widens with N
livekit-agents (process per session):   1861 MB total  (116.3 MB/session)
OpenRTC coroutine pool (one process):     195 MB total  ( 12.2 MB/session)
OpenRTC uses 9.5x less memory for the same 16 sessions.

Routing

One worker hosts several agent classes, so each session resolves to one registered name. The chain is evaluated in order, and the first match wins:

  1. ctx.job.metadata["agent"]
  2. ctx.job.metadata["demo"]
  3. room metadata ["agent"]
  4. room metadata ["demo"]
  5. room-name prefix match (agent name followed by a literal hyphen, e.g. restaurant-call-123)
  6. first registered agent (fallback)

Within a source, agent outranks demo. Metadata may be a JSON object string or a mapping; blank strings, non-JSON strings, and JSON scalars are ignored and defer to the next strategy. The room-metadata strategies read ctx.job.room.metadata first (authoritative before ctx.connect(), when ctx.room.metadata is still empty).

A value naming an unregistered agent raises eagerly instead of falling through: ValueError("Unknown agent '<name>' requested via <job metadata|room metadata>."). An empty pool raises RuntimeError("No agents are registered in the pool."). Routing never falls back silently. Full rules: routing docs.

Session observers

Attach external telemetry to every session without subclassing or touching internals. Any object with two async methods satisfies the SessionObserver protocol (structural typing, no base class):

from openrtc import AgentPool, SessionInfo, SessionOutcome


class LoggingObserver:
    async def on_session_start(self, info: SessionInfo, session: object) -> None:
        print(f"live: {info.agent_name} in {info.room_name}")

    async def on_session_end(self, info: SessionInfo, outcome: SessionOutcome) -> None:
        print(f"done: {info.agent_name} -> {outcome.status.value}")


pool = AgentPool(observers=[LoggingObserver()])   # or pool.add_observer(...)

on_session_start receives the live AgentSession (subscribe to its metrics there). on_session_end receives a SessionOutcome with status SUCCESS, FAILED, or CANCELLED, and may fire without a matching start if a session dies before going live. Observer calls are isolated: a slow or raising observer is logged and skipped, never crashing the session. Register before run(); under process isolation an observer must be picklable, so build live resources lazily inside on_session_start.

Hot reload

Edit an agent file while calls are in flight, and OpenRTC swaps every live session to the new class on its next turn. This is something livekit-agents cannot do (each session is its own process); OpenRTC can because the agent class is a shared-memory object.

openrtc dev ./agents        # coroutine mode watches your files; on by default
openrtc dev ./agents --no-watch          # opt out
openrtc dev ./agents --watch-path ./lib  # watch extra paths

On save, the module is re-imported into a fresh namespace and validated (compile + import) before any swap. livekit's update_agent blocks new turns and drains the in-flight one, so the current turn finishes on the old class and the next runs the new, with no dropped audio. Guarantees:

  • Rollback-safe. A SyntaxError, ImportError, or missing Agent subclass keeps the running class and logs the error with file:line. A bad save never poisons the pool.

  • Loud feedback. Each reload logs [reload] agent.py changed -> swapped N sessions in Xms.

  • Opt-out for critical flows. Wrap a block that must not change class mid-flight:

    from openrtc import pin_reload
    
    with pin_reload(ctx.session):
        ...  # payment confirmation, multi-step auth: no swap until this exits
    

Hot reload is coroutine-mode only (process mode runs one subprocess per session). openrtc start never hot reloads. Enable it programmatically with AgentPool(enable_hot_reload=True).

CLI

Install openrtc[cli] to put openrtc on your PATH. Five subcommands mirror the LiveKit Agents shape (start, dev, console, connect, download-files), plus an OpenRTC-only list. Pass the agents directory as the first positional path instead of --agents-dir.

openrtc list ./agents \
  --default-stt openai/gpt-4o-mini-transcribe \
  --default-llm openai/gpt-4.1-mini \
  --default-tts openai/gpt-4o-mini-tts

openrtc start ./agents                           # production worker (after exporting LIVEKIT_*)
openrtc dev   ./agents ./openrtc-metrics.jsonl   # 2nd positional path = --metrics-jsonl

Flags are scoped per command: --json / --plain / --resources on list; --isolation / --max-concurrent-sessions on the worker commands; --no-watch / --watch-path control hot reload on dev; the metrics and dashboard flags on the worker commands and connect. --metrics-jsonl appends one JSON object per line (an envelope of schema_version, kind (snapshot or event), seq, wall_time_unix, and payload), interleaving pool snapshots with session_started / session_finished / session_failed events for tail -f or jq. OpenRTC-only flags are stripped before the handoff to LiveKit's CLI parser. Full flag lists: docs/cli.md.

Architecture

flowchart TB
    LK[LiveKit dispatch] --> POOL[AgentPool: one worker process]
    POOL --> PW[Shared prewarm: Silero VAD plus turn detector, loaded once]
    POOL --> ROUTE{Routing chain}
    ROUTE -->|job metadata| REG[Registered Agent subclasses]
    ROUTE -->|room metadata| REG
    ROUTE -->|room-name prefix| REG
    ROUTE -->|first-registered fallback| REG
    REG --> SESS[Per-session AgentSession: one asyncio.Task each]
    PW --> SESS
    SESS --> OBS[SessionObservers]
    SESS --> SINK[runtime_snapshot plus JSONL metrics sink]

Prewarm runs once as the worker's setup function and caches VAD and turn detector in proc.userdata. For each job the universal entrypoint runs the routing chain, instantiates the chosen Agent subclass, builds an AgentSession from cached defaults plus per-agent overrides, and starts it as a task on the shared loop. Registration data is spawn-safe, so it survives serialization to worker subprocesses. Architecture deep dive.

Public API at a glance

The public surface is exactly openrtc.__all__, 14 names. Everything else is internal and not treated as stable.

Export What it is
AgentPool The pool facade. Register agents, run one worker.
AgentConfig Per-agent registration record from add() / discover() (spawn-safe dataclass).
AgentDiscoveryConfig Per-file discovery metadata attached by @agent_config.
agent_config Keyword-only decorator tagging an Agent subclass with name/stt/llm/tts/greeting.
ProviderValue Type alias str | object for STT/LLM/TTS slots (provider ID string or plugin instance).
SessionObserver @runtime_checkable protocol: async on_session_start / on_session_end.
SessionInfo Frozen dataclass: agent_name, room_name, job_id, metadata, started_at.
SessionOutcome Frozen dataclass: status, error, ended_at, duration_seconds.
SessionStatus Enum: SUCCESS, FAILED, CANCELLED.
FileWatcher / FileChange watchfiles-backed hot-reload watcher and its change record.
pin_reload / is_pinned Context manager to exclude a session from mid-flow class swaps, and its predicate.
__version__ Resolved from importlib.metadata.

AgentPool(...) (all keyword-only, all optional):

Parameter Default Purpose
default_stt / default_llm / default_tts / default_greeting None Pool-wide defaults applied when add() / discover() does not override them.
observers None Sequence[SessionObserver] registered during init.
isolation "coroutine" "coroutine" or "process" worker isolation mode.
max_concurrent_sessions 50 Coroutine backpressure threshold (positive int).
consecutive_failure_limit 5 Coroutine supervisor threshold (positive int).
drain_timeout 30 Seconds to wait for in-flight sessions after SIGTERM (positive int).
enable_hot_reload False Watch agent files and swap live sessions on the next turn (coroutine mode only).
watch_paths None Extra paths to watch; None auto-discovers the worker's user modules.

Methods: add, discover, list_agents, get, remove, add_observer, run, runtime_snapshot, drain_metrics_stream_events. Read-only properties: isolation, max_concurrent_sessions, consecutive_failure_limit, drain_timeout, enable_hot_reload, server. add() raises on an empty or duplicate name and on an agent_cls that is not a livekit.agents.Agent subclass; direct **session_options override the same keys in session_kwargs.

Project structure
src/openrtc/
├── __init__.py
├── py.typed
├── core/                  # foundational, flat (pool, config, discovery, wiring)
│   ├── pool.py            # AgentPool facade
│   ├── config.py          # AgentConfig, AgentDiscoveryConfig, agent_config
│   ├── discovery.py       # file-system discovery helpers
│   ├── serialization.py   # spawn-safe config serialization
│   ├── turn_handling.py   # turn-detector integration
│   └── wiring.py          # AgentSession assembly helpers
├── routing/               # base_routing.py + variant siblings + resolver
│   ├── base_routing.py    # RoutingStrategy protocol
│   ├── metadata_routing.py
│   ├── room_prefix_routing.py
│   ├── default_routing.py
│   └── resolver.py        # ordered strategy chain
├── runtime/               # base_runtime.py + variant siblings + registry
│   ├── base_runtime.py    # RuntimeBackend protocol
│   ├── coroutine_runtime.py
│   ├── process_runtime.py
│   ├── coroutine_server.py
│   ├── prewarm.py         # shared prewarm helpers
│   ├── resources.py       # shared resource cache
│   ├── file_watcher.py    # FileWatcher / FileChange, hot reload
│   └── registry.py        # selects active runtime
├── observability/         # base_observer.py + base_sink.py + concretes
│   ├── base_observer.py   # SessionObserver protocol
│   ├── base_sink.py       # metrics sink protocol
│   ├── jsonl_sink.py      # JSONL metrics schema and writer
│   ├── metrics.py         # RuntimeMetricsStore, footprint helpers
│   ├── snapshot.py        # PoolRuntimeSnapshot dataclass
│   ├── resident_set.py    # RSS memory helpers
│   ├── savings.py         # cost-savings estimator
│   └── footprint.py       # per-session memory footprint
├── cli/                   # base_cli.py + variant siblings
│   ├── base_cli.py        # shared Typer args and parameter bundles
│   ├── main_cli.py        # top-level Typer app and subcommands
│   ├── dashboard_cli.py   # Rich dashboard and list output
│   ├── entry_cli.py       # lazy console entry / missing-extra hint
│   ├── livekit_cli.py     # LiveKit argv/env handoff, pool run
│   └── reporter_cli.py    # background metrics reporter thread
└── utils/                 # foundational, flat
    ├── types.py           # ProviderValue and related typing
    └── validation.py      # input validation helpers

Contributing

git clone https://github.com/mahimailabs/openrtc-runtime
cd openrtc-runtime
uv sync --group dev
uv run pytest

Read CONTRIBUTING.md before opening a PR. CI runs Ruff and mypy (strict) alongside the suite, with a combined line + branch coverage gate at 99%.

Community

Star History Chart

Contributors

License

MIT. Fork it, ship it.

Built by Mahimai Raja, founder of Mahimai AI, a voice AI company, in public. Standing on LiveKit Agents.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

openrtc-0.10.0.tar.gz (1.9 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

openrtc-0.10.0-py3-none-any.whl (84.0 kB view details)

Uploaded Python 3

File details

Details for the file openrtc-0.10.0.tar.gz.

File metadata

  • Download URL: openrtc-0.10.0.tar.gz
  • Upload date:
  • Size: 1.9 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for openrtc-0.10.0.tar.gz
Algorithm Hash digest
SHA256 a8c34467683be1610d872558b99e7813b858b961a7d661c1c70a8a814c531b63
MD5 2e432cee6d83b95c40ab2cba5674aa21
BLAKE2b-256 1d0c29cb70f5836125152bc737d636d0c6f7d3b08ee6f47e797d9b6a371d6594

See more details on using hashes here.

File details

Details for the file openrtc-0.10.0-py3-none-any.whl.

File metadata

  • Download URL: openrtc-0.10.0-py3-none-any.whl
  • Upload date:
  • Size: 84.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for openrtc-0.10.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dc65664cc67827848504209a40ddfe9cd54ccd96ed09ffe850b6f6bdc69f9114
MD5 0768c9acb2e9a8ad45c39eb1ddc16885
BLAKE2b-256 32ea9ccd3e67e59e3be892199d6f839b8f7237ebc2075ef41bc10bf90431a9bc

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page