From ae03ee99f7a530fd36e99742e8da44b14cff0636 Mon Sep 17 00:00:00 2001 From: Stephen Allen Date: Fri, 21 Aug 2026 12:15:09 -0500 Subject: [PATCH] feat(integrations): Add LiveKit runner for voice and telephony ADK had no first-party connector for realtime media, so reaching a live agent over WebRTC or SIP meant hand-rolling a bridge onto LiveRequestQueue. LiveKitRunner takes an already-connected rtc.Room and bridges it to an unmodified Runner over the transport-agnostic LiveRequestQueue -> run_live() -> Event contract, publishing on LiveKit's standard channels so their client SDKs render an ADK agent unchanged. --- constraints-3.10.txt | 28 +- constraints-3.11.txt | 28 +- constraints-3.12.txt | 28 +- constraints-3.13.txt | 28 +- constraints-3.14.txt | 28 +- .../integrations/livekit_agent/README.md | 132 ++ .../integrations/livekit_agent/__init__.py | 15 + .../integrations/livekit_agent/_common.py | 39 + .../integrations/livekit_agent/_web.py | 39 + .../integrations/livekit_agent/agent.py | 117 ++ .../integrations/livekit_agent/client/main.py | 75 + .../livekit_agent/client/static/index.html | 88 ++ .../livekit_agent/client/static/script.js | 362 +++++ .../livekit_agent/client/static/style.css | 384 +++++ .../livekit_agent/livekit_worker.py | 53 + .../integrations/livekit_agent/quickstart.py | 117 ++ pyproject.toml | 11 + src/google/adk/features/_feature_registry.py | 4 + src/google/adk/integrations/livekit/README.md | 234 +++ .../adk/integrations/livekit/__init__.py | 64 + .../adk/integrations/livekit/_call_context.py | 309 ++++ .../adk/integrations/livekit/_call_tools.py | 89 ++ .../adk/integrations/livekit/_call_toolset.py | 93 ++ .../integrations/livekit/_livekit_runner.py | 908 ++++++++++++ src/google/adk/integrations/livekit/_rtc.py | 31 + .../adk/integrations/livekit/_transcripts.py | 104 ++ .../integrations/livekit/conftest.py | 163 +++ .../integrations/livekit/test_call_tools.py | 489 +++++++ .../livekit/test_livekit_runner.py | 1294 +++++++++++++++++ .../livekit/test_livekit_workflow.py | 133 ++ 30 files changed, 5482 insertions(+), 5 deletions(-) create mode 100644 contributing/samples/integrations/livekit_agent/README.md create mode 100644 contributing/samples/integrations/livekit_agent/__init__.py create mode 100644 contributing/samples/integrations/livekit_agent/_common.py create mode 100644 contributing/samples/integrations/livekit_agent/_web.py create mode 100644 contributing/samples/integrations/livekit_agent/agent.py create mode 100644 contributing/samples/integrations/livekit_agent/client/main.py create mode 100644 contributing/samples/integrations/livekit_agent/client/static/index.html create mode 100644 contributing/samples/integrations/livekit_agent/client/static/script.js create mode 100644 contributing/samples/integrations/livekit_agent/client/static/style.css create mode 100644 contributing/samples/integrations/livekit_agent/livekit_worker.py create mode 100644 contributing/samples/integrations/livekit_agent/quickstart.py create mode 100644 src/google/adk/integrations/livekit/README.md create mode 100644 src/google/adk/integrations/livekit/__init__.py create mode 100644 src/google/adk/integrations/livekit/_call_context.py create mode 100644 src/google/adk/integrations/livekit/_call_tools.py create mode 100644 src/google/adk/integrations/livekit/_call_toolset.py create mode 100644 src/google/adk/integrations/livekit/_livekit_runner.py create mode 100644 src/google/adk/integrations/livekit/_rtc.py create mode 100644 src/google/adk/integrations/livekit/_transcripts.py create mode 100644 tests/unittests/integrations/livekit/conftest.py create mode 100644 tests/unittests/integrations/livekit/test_call_tools.py create mode 100644 tests/unittests/integrations/livekit/test_livekit_runner.py create mode 100644 tests/unittests/integrations/livekit/test_livekit_workflow.py diff --git a/constraints-3.10.txt b/constraints-3.10.txt index 25cb0463fdb..5fa550754a6 100644 --- a/constraints-3.10.txt +++ b/constraints-3.10.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --all-extras --python-version 3.10 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.10.txt +# uv pip compile pyproject.toml --all-extras --python-version 3.10 --no-emit-package google-adk --exclude-newer 2026-08-17 --index-url https://pypi.org/simple -o constraints-3.10.txt a2a-sdk==1.1.2 # via # -c constraints-3.10.txt.stable.tmp @@ -17,6 +17,7 @@ aiofiles==25.1.0 # via # -c constraints-3.10.txt.stable.tmp # daytona + # livekit aiohappyeyeballs==2.7.1 # via # -c constraints-3.10.txt.stable.tmp @@ -35,6 +36,7 @@ aiohttp==3.14.1 # kubernetes # langchain-community # litellm + # livekit-api # llama-index-core # python-socketio # toolbox-core @@ -887,6 +889,18 @@ litellm==1.85.7 # -c constraints-3.10.txt.stable.tmp # google-adk (pyproject.toml) # google-cloud-aiplatform +livekit==1.1.14 + # via + # -c constraints-3.10.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-api==1.2.0 + # via + # -c constraints-3.10.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-protocol==1.1.22 + # via + # -c constraints-3.10.txt.stable.tmp + # livekit-api llama-index-core==0.14.23 # via # -c constraints-3.10.txt.stable.tmp @@ -1006,6 +1020,7 @@ numpy==2.2.6 # via # -c constraints-3.10.txt.stable.tmp # langchain-community + # livekit # llama-index-core # pandas # rouge-score @@ -1195,6 +1210,7 @@ pathspec==1.1.1 pillow==12.3.0 # via # -c constraints-3.10.txt.stable.tmp + # google-adk (pyproject.toml) # llama-index-core pip==26.1.2 # via @@ -1291,6 +1307,9 @@ protobuf==6.33.6 # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status + # livekit + # livekit-api + # livekit-protocol # opentelemetry-proto # proto-plus pyarrow==25.0.0 @@ -1367,6 +1386,7 @@ pyink==25.12.0 pyjwt==2.13.0 # via # -c constraints-3.10.txt.stable.tmp + # livekit-api # mcp # oci # redis @@ -1786,6 +1806,12 @@ tqdm==4.69.0 # llama-index-core # nltk # openai +types-protobuf==7.34.1.20260816 + # via + # -c constraints-3.10.txt.stable.tmp + # livekit + # livekit-api + # livekit-protocol typing-extensions==4.16.0 # via # -c constraints-3.10.txt.stable.tmp diff --git a/constraints-3.11.txt b/constraints-3.11.txt index 14358a25741..fc85f235962 100644 --- a/constraints-3.11.txt +++ b/constraints-3.11.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --all-extras --python-version 3.11 --no-emit-package google-adk --exclude-newer 2026-08-09 --index-url https://pypi.org/simple -o constraints-3.11.txt +# uv pip compile pyproject.toml --all-extras --python-version 3.11 --no-emit-package google-adk --exclude-newer 2026-08-17 --index-url https://pypi.org/simple -o constraints-3.11.txt a2a-sdk==1.1.1 # via # -c constraints-3.11.txt.stable.tmp @@ -18,6 +18,7 @@ aiofiles==24.1.0 # -c constraints-3.11.txt.stable.tmp # crewai # daytona + # livekit aiohappyeyeballs==2.7.1 # via # -c constraints-3.11.txt.stable.tmp @@ -37,6 +38,7 @@ aiohttp==3.14.1 # kubernetes # langchain-community # litellm + # livekit-api # llama-index-core # python-socketio # toolbox-core @@ -1006,6 +1008,18 @@ litellm==1.85.7 # -c constraints-3.11.txt.stable.tmp # google-adk (pyproject.toml) # google-cloud-aiplatform +livekit==1.1.14 + # via + # -c constraints-3.11.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-api==1.2.0 + # via + # -c constraints-3.11.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-protocol==1.1.22 + # via + # -c constraints-3.11.txt.stable.tmp + # livekit-api llama-index-core==0.14.23 # via # -c constraints-3.11.txt.stable.tmp @@ -1136,6 +1150,7 @@ numpy==2.4.6 # chromadb # lancedb # langchain-community + # livekit # llama-index-core # onnxruntime # pandas @@ -1373,6 +1388,7 @@ pendulum==3.2.0 pillow==12.3.0 # via # -c constraints-3.11.txt.stable.tmp + # google-adk (pyproject.toml) # llama-index-core # pdfplumber pip==26.1.2 @@ -1480,6 +1496,9 @@ protobuf==6.33.6 # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status + # livekit + # livekit-api + # livekit-protocol # onnxruntime # opentelemetry-proto # proto-plus @@ -1576,6 +1595,7 @@ pyjwt==2.13.0 # crewai # crewai-cli # crewai-core + # livekit-api # mcp # oci # redis @@ -2056,6 +2076,12 @@ typer==0.27.0 # -c constraints-3.11.txt.stable.tmp # chromadb # instructor +types-protobuf==7.34.1.20260816 + # via + # -c constraints-3.11.txt.stable.tmp + # livekit + # livekit-api + # livekit-protocol typing-extensions==4.16.0 # via # -c constraints-3.11.txt.stable.tmp diff --git a/constraints-3.12.txt b/constraints-3.12.txt index d0a37eb47f9..7ae61f9061c 100644 --- a/constraints-3.12.txt +++ b/constraints-3.12.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --all-extras --python-version 3.12 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.12.txt +# uv pip compile pyproject.toml --all-extras --python-version 3.12 --no-emit-package google-adk --exclude-newer 2026-08-17 --index-url https://pypi.org/simple -o constraints-3.12.txt a2a-sdk==1.1.1 # via # -c constraints-3.12.txt.stable.tmp @@ -17,6 +17,7 @@ aiofiles==25.1.0 # via # -c constraints-3.12.txt.stable.tmp # daytona + # livekit aiohappyeyeballs==2.7.1 # via # -c constraints-3.12.txt.stable.tmp @@ -35,6 +36,7 @@ aiohttp==3.14.1 # kubernetes # langchain-community # litellm + # livekit-api # llama-index-core # python-socketio # toolbox-core @@ -871,6 +873,18 @@ litellm==1.85.7 # -c constraints-3.12.txt.stable.tmp # google-adk (pyproject.toml) # google-cloud-aiplatform +livekit==1.1.14 + # via + # -c constraints-3.12.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-api==1.2.0 + # via + # -c constraints-3.12.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-protocol==1.1.22 + # via + # -c constraints-3.12.txt.stable.tmp + # livekit-api llama-index-core==0.14.23 # via # -c constraints-3.12.txt.stable.tmp @@ -994,6 +1008,7 @@ numpy==2.5.1 # via # -c constraints-3.12.txt.stable.tmp # langchain-community + # livekit # llama-index-core # pandas # rouge-score @@ -1183,6 +1198,7 @@ pathspec==1.1.1 pillow==12.3.0 # via # -c constraints-3.12.txt.stable.tmp + # google-adk (pyproject.toml) # llama-index-core pip==26.1.2 # via @@ -1279,6 +1295,9 @@ protobuf==6.33.6 # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status + # livekit + # livekit-api + # livekit-protocol # opentelemetry-proto # proto-plus pyarrow==25.0.0 @@ -1355,6 +1374,7 @@ pyink==25.12.0 pyjwt==2.13.0 # via # -c constraints-3.12.txt.stable.tmp + # livekit-api # mcp # oci # redis @@ -1765,6 +1785,12 @@ tqdm==4.69.0 # llama-index-core # nltk # openai +types-protobuf==7.34.1.20260816 + # via + # -c constraints-3.12.txt.stable.tmp + # livekit + # livekit-api + # livekit-protocol typing-extensions==4.16.0 # via # -c constraints-3.12.txt.stable.tmp diff --git a/constraints-3.13.txt b/constraints-3.13.txt index 5dcb92471f7..2d27d257081 100644 --- a/constraints-3.13.txt +++ b/constraints-3.13.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --all-extras --python-version 3.13 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.13.txt +# uv pip compile pyproject.toml --all-extras --python-version 3.13 --no-emit-package google-adk --exclude-newer 2026-08-17 --index-url https://pypi.org/simple -o constraints-3.13.txt a2a-sdk==1.1.1 # via # -c constraints-3.13.txt.stable.tmp @@ -17,6 +17,7 @@ aiofiles==25.1.0 # via # -c constraints-3.13.txt.stable.tmp # daytona + # livekit aiohappyeyeballs==2.7.1 # via # -c constraints-3.13.txt.stable.tmp @@ -35,6 +36,7 @@ aiohttp==3.14.1 # kubernetes # langchain-community # litellm + # livekit-api # llama-index-core # python-socketio # toolbox-core @@ -863,6 +865,18 @@ litellm==1.85.7 # -c constraints-3.13.txt.stable.tmp # google-adk (pyproject.toml) # google-cloud-aiplatform +livekit==1.1.14 + # via + # -c constraints-3.13.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-api==1.2.0 + # via + # -c constraints-3.13.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-protocol==1.1.22 + # via + # -c constraints-3.13.txt.stable.tmp + # livekit-api llama-index-core==0.14.23 # via # -c constraints-3.13.txt.stable.tmp @@ -986,6 +1000,7 @@ numpy==2.5.1 # via # -c constraints-3.13.txt.stable.tmp # langchain-community + # livekit # llama-index-core # pandas # rouge-score @@ -1175,6 +1190,7 @@ pathspec==1.1.1 pillow==12.3.0 # via # -c constraints-3.13.txt.stable.tmp + # google-adk (pyproject.toml) # llama-index-core pip==26.1.2 # via @@ -1271,6 +1287,9 @@ protobuf==6.33.6 # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status + # livekit + # livekit-api + # livekit-protocol # opentelemetry-proto # proto-plus pyarrow==25.0.0 @@ -1347,6 +1366,7 @@ pyink==25.12.0 pyjwt==2.13.0 # via # -c constraints-3.13.txt.stable.tmp + # livekit-api # mcp # oci # redis @@ -1756,6 +1776,12 @@ tqdm==4.69.0 # llama-index-core # nltk # openai +types-protobuf==7.34.1.20260816 + # via + # -c constraints-3.13.txt.stable.tmp + # livekit + # livekit-api + # livekit-protocol typing-extensions==4.16.0 # via # -c constraints-3.13.txt.stable.tmp diff --git a/constraints-3.14.txt b/constraints-3.14.txt index 2f6b1b5e8f9..2a995141647 100644 --- a/constraints-3.14.txt +++ b/constraints-3.14.txt @@ -1,5 +1,5 @@ # This file was autogenerated by uv via the following command: -# uv pip compile pyproject.toml --all-extras --python-version 3.14 --no-emit-package google-adk --exclude-newer 2026-08-10 --index-url https://pypi.org/simple -o constraints-3.14.txt +# uv pip compile pyproject.toml --all-extras --python-version 3.14 --no-emit-package google-adk --exclude-newer 2026-08-17 --index-url https://pypi.org/simple -o constraints-3.14.txt a2a-sdk==1.1.1 # via # -c constraints-3.14.txt.stable.tmp @@ -17,6 +17,7 @@ aiofiles==25.1.0 # via # -c constraints-3.14.txt.stable.tmp # daytona + # livekit aiohappyeyeballs==2.7.1 # via # -c constraints-3.14.txt.stable.tmp @@ -35,6 +36,7 @@ aiohttp==3.14.1 # kubernetes # langchain-community # litellm + # livekit-api # llama-index-core # python-socketio # toolbox-core @@ -863,6 +865,18 @@ litellm==1.85.7 # -c constraints-3.14.txt.stable.tmp # google-adk (pyproject.toml) # google-cloud-aiplatform +livekit==1.1.14 + # via + # -c constraints-3.14.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-api==1.2.0 + # via + # -c constraints-3.14.txt.stable.tmp + # google-adk (pyproject.toml) +livekit-protocol==1.1.22 + # via + # -c constraints-3.14.txt.stable.tmp + # livekit-api llama-index-core==0.14.23 # via # -c constraints-3.14.txt.stable.tmp @@ -986,6 +1000,7 @@ numpy==2.5.1 # via # -c constraints-3.14.txt.stable.tmp # langchain-community + # livekit # llama-index-core # pandas # rouge-score @@ -1175,6 +1190,7 @@ pathspec==1.1.1 pillow==12.3.0 # via # -c constraints-3.14.txt.stable.tmp + # google-adk (pyproject.toml) # llama-index-core pip==26.1.2 # via @@ -1271,6 +1287,9 @@ protobuf==6.33.6 # googleapis-common-protos # grpc-google-iam-v1 # grpcio-status + # livekit + # livekit-api + # livekit-protocol # opentelemetry-proto # proto-plus pyarrow==25.0.0 @@ -1347,6 +1366,7 @@ pyink==25.12.0 pyjwt==2.13.0 # via # -c constraints-3.14.txt.stable.tmp + # livekit-api # mcp # oci # redis @@ -1756,6 +1776,12 @@ tqdm==4.69.0 # llama-index-core # nltk # openai +types-protobuf==7.34.1.20260816 + # via + # -c constraints-3.14.txt.stable.tmp + # livekit + # livekit-api + # livekit-protocol typing-extensions==4.16.0 # via # -c constraints-3.14.txt.stable.tmp diff --git a/contributing/samples/integrations/livekit_agent/README.md b/contributing/samples/integrations/livekit_agent/README.md new file mode 100644 index 00000000000..8d15fb46780 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/README.md @@ -0,0 +1,132 @@ +# LiveKit Voice Agent + +## Overview + +Talk to an unmodified ADK live agent over WebRTC, using the +[LiveKit integration](../../../../src/google/adk/integrations/livekit/README.md). +A browser joins a LiveKit room, the ADK agent joins the same room, and you have +a conversation. + +`agent.py` is a plain ADK live agent that rolls dice and checks primes. It has +no transport code, and `adk web` still runs it: the one LiveKit thing it +declares, `LiveKitToolset()`, offers nothing when there is no call. The only +line that joins agent to transport is: + +```python +await LiveKitRunner(runner=runner, room=room, user_id=..., session_id=...).start() +``` + +The sample shows both ways to put an agent in a room. Same agent, same +connector, same browser client; they differ only in who joins the agent. + +| | Quickstart | Worker | +| :------------------ | :--------------------------- | :------------------------------------- | +| File | `quickstart.py` | `livekit_worker.py` + `client/main.py` | +| Processes | one | two | +| Agent joins because | your web app connects it | LiveKit dispatch spawns it | +| Good for | local dev, small deployments | production, telephony, autoscaling | + +## Prerequisites + +A LiveKit server. Either a free local dev server or LiveKit Cloud works +unchanged. Install the server (`brew install livekit` on macOS, or see +[self-hosting](https://docs.livekit.io/home/self-hosting/local/)) and run it in +its own terminal: + +```bash +livekit-server --dev +``` + +`--dev` uses a fixed, public key and secret. Point the sample at it: + +```bash +export LIVEKIT_URL="ws://localhost:7880" +export LIVEKIT_API_KEY="devkey" +export LIVEKIT_API_SECRET="secret" +``` + +For LiveKit Cloud, use the values from your +[project dashboard](https://cloud.livekit.io/) instead. Either way, also set +your Vertex/Gemini credentials for `gemini-live-2.5-flash-native-audio`. + +## How To + +### Quickstart, one process + +```bash +pip install "google-adk[livekit]" fastapi uvicorn +python -m contributing.samples.integrations.livekit_agent.quickstart +``` + +Open and click **Start talking**. Microphone and camera +capture need a secure context, and `http://localhost` counts as one, so there +is no HTTPS setup locally. + +### Worker, two processes + +The shape you deploy. The worker registers with LiveKit and idles until a call +needs it. + +```bash +pip install "google-adk[livekit]" "livekit-agents>=1.4" fastapi uvicorn +``` + +Terminal 1, the ADK worker: + +```bash +python -m contributing.samples.integrations.livekit_agent.livekit_worker start +``` + +Terminal 2, the token and dispatch server, which also serves the client: + +```bash +python -m contributing.samples.integrations.livekit_agent.client.main +``` + +Open and talk, as above. To check the worker without a +browser, dispatch a job directly: + +```bash +lk dispatch create --room smoke-test --agent-name roll_dice \ + --metadata '{"user_id":"tester","session_id":"smoke-test"}' +``` + +## Sample Inputs + +- *"Roll a 20 sided die and tell me if it's prime."* Watch the **Tool activity** + panel: `roll_die` goes out with its arguments, sits at *calling…*, then flips + to *returned*, followed by `check_prime` taking that number. +- Interrupt it mid-sentence. It stops rather than talking over you for the + second or so of speech already buffered. +- Click **Turn on camera**, hold up a real die, and ask *"what am I holding?"* +- Type instead of talking. +- Say goodbye. It calls `end_call` and hangs up. + +## Writing your own client + +The browser client in `client/static/` uses LiveKit's standard channels, so any +LiveKit frontend works instead — point the +[Agents Playground](https://docs.livekit.io/agents/start/playground/) at the +same server and you get audio, captions, camera and a speaking indicator. + +Three things are worth knowing if you write your own: + +- **Tool activity is the one ADK-specific channel**, published as JSON on an + `adk` data topic. `handleAdkData` in `client/static/script.js` is the whole + of it; pair a result to its call on `id`, not on the tool `name`. The + Playground will not show this panel. +- **The transcript is a merge of `lk.transcription` and `lk.chat`**, as in + LiveKit's `useChatAndTranscription`. Nothing transcribes a typed turn or a + keypad entry, so those reach the chat topic only. Echo your own outgoing + messages, which LiveKit does not deliver back to their sender. +- **Ask a participant whether it `isAgent`** rather than treating "not me" as + the agent, which is wrong as soon as a third party joins. + +## Notes + +This sample is WebRTC only. See the +[integration README](../../../../src/google/adk/integrations/livekit/README.md#telephony) +for the phone-call story, and its +[production notes](../../../../src/google/adk/integrations/livekit/README.md#production-notes) +before deploying — both entry points use `InMemoryRunner`, which does not share +or persist sessions across processes. diff --git a/contributing/samples/integrations/livekit_agent/__init__.py b/contributing/samples/integrations/livekit_agent/__init__.py new file mode 100644 index 00000000000..4015e47d6e4 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Google LLC +# +# 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. + +from . import agent diff --git a/contributing/samples/integrations/livekit_agent/_common.py b/contributing/samples/integrations/livekit_agent/_common.py new file mode 100644 index 00000000000..acf63f31db7 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/_common.py @@ -0,0 +1,39 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""Config shared by both entry points.""" + +from __future__ import annotations + +import os + +# Also the LiveKit `agent_name` the worker registers and the client dispatches. +APP_NAME = "roll_dice" + + +def require_env(name: str) -> str: + """Returns an environment variable, or explains which one is missing.""" + try: + return os.environ[name] + except KeyError: + raise RuntimeError(f"{name} is not set; see README.md.") from None + + +def livekit_credentials() -> tuple[str, str, str]: + """Returns the LiveKit URL, API key and API secret from the environment.""" + return ( + require_env("LIVEKIT_URL"), + require_env("LIVEKIT_API_KEY"), + require_env("LIVEKIT_API_SECRET"), + ) diff --git a/contributing/samples/integrations/livekit_agent/_web.py b/contributing/samples/integrations/livekit_agent/_web.py new file mode 100644 index 00000000000..16b4ada93de --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/_web.py @@ -0,0 +1,39 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""Serves the browser client, for the two entry points that have one.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any +from typing import Optional + +from fastapi import FastAPI +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +STATIC_DIR = Path(__file__).parent / "client" / "static" + + +def make_app(lifespan: Optional[Any] = None) -> FastAPI: + """Builds the FastAPI app that serves the browser client.""" + app = FastAPI(lifespan=lifespan) + app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static") + + @app.get("/") + async def index() -> FileResponse: + return FileResponse(STATIC_DIR / "index.html") + + return app diff --git a/contributing/samples/integrations/livekit_agent/agent.py b/contributing/samples/integrations/livekit_agent/agent.py new file mode 100644 index 00000000000..4dd6fe119d2 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/agent.py @@ -0,0 +1,117 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""A live voice agent that rolls dice, checks primes, and can see. + +No transport code: `adk web` runs it, and both entry points in this directory +put this same `root_agent` behind a LiveKit room without changing this file. +""" + +import random + +from google.adk.agents.llm_agent import Agent +from google.adk.integrations.livekit import LiveKitToolset +from google.adk.tools.tool_context import ToolContext +from google.genai import types + + +def roll_die(sides: int, tool_context: ToolContext) -> int: + """Roll a die and return the rolled result. + + Args: + sides: The integer number of sides the die has. + + Returns: + An integer of the result of rolling the die. + """ + result = random.randint(1, sides) + if 'rolls' not in tool_context.state: + tool_context.state['rolls'] = [] + + tool_context.state['rolls'] = tool_context.state['rolls'] + [result] + return result + + +async def check_prime(nums: list[int]) -> str: + """Check if a given list of numbers are prime. + + Args: + nums: The list of numbers to check. + + Returns: + A str indicating which number is prime. + """ + primes = set() + for number in nums: + number = int(number) + if number <= 1: + continue + is_prime = True + for i in range(2, int(number**0.5) + 1): + if number % i == 0: + is_prime = False + break + if is_prime: + primes.add(number) + return ( + 'No prime numbers found.' + if not primes + else f"{', '.join(str(num) for num in primes)} are prime numbers." + ) + + +root_agent = Agent( + # Find supported models in Vertex here: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/live-api + model='gemini-live-2.5-flash-native-audio', # Vertex + # Find supported models in Gemini API here: https://ai.google.dev/gemini-api/docs/models + # model='gemini-2.5-flash-native-audio-preview-12-2025', # Gemini API + name='roll_dice_agent', + description=( + 'hello world agent that can roll a dice of 6 sides and check prime' + ' numbers.' + ), + instruction=""" + You roll dice and answer questions about the outcome of the dice rolls. + You can roll dice of different sizes. When the user doesn't specify the number of sides, you should assume 6 sides. + You can use multiple tools in parallel by calling functions in parallel(in one request and in one round). + It is ok to discuss previous dice roles, and comment on the dice rolls. + When you are asked to roll a die, you must call the roll_die tool with the number of sides. Be sure to pass in an integer. Do not pass in a string. + You should never roll a die on your own. + When checking prime numbers, call the check_prime tool with a list of integers. Be sure to pass in a list of integers. You should never pass in a string. + You should not check prime numbers before calling the tool. + When you are asked to roll a die and check prime numbers, you should always make the following two function calls: + 1. You should first call the roll_die tool to get a roll. Wait for the function response before calling the check_prime tool. + 2. After you get the function response from roll_die tool, you should call the check_prime tool with the roll_die result. + 2.1 If user asks you to check primes based on previous rolls, make sure you include the previous rolls in the list. + 3. When you respond, you must include the roll_die result from step 1. + You should always perform the previous 3 steps when asking for a roll and checking prime numbers. + You should not rely on the previous history on prime results. + If the user has a camera on you can see them, so answer questions about what is in view. + If they hold up a real die, read the number off it and you may use that number with check_prime, since you did not roll it. + """, + tools=[ + roll_die, + check_prime, + # Offers nothing when there is no call, so `adk web` still works. + LiveKitToolset(), + ], + generate_content_config=types.GenerateContentConfig( + safety_settings=[ + types.SafetySetting( # avoid false alarm about rolling dice. + category=types.HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, + threshold=types.HarmBlockThreshold.OFF, + ), + ] + ), +) diff --git a/contributing/samples/integrations/livekit_agent/client/main.py b/contributing/samples/integrations/livekit_agent/client/main.py new file mode 100644 index 00000000000..da82ef88fb8 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/client/main.py @@ -0,0 +1,75 @@ +# Copyright 2026 Google LLC +# +# 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. + +"""Token and dispatch server for the LiveKit browser client. + +A browser cannot hold the LiveKit API secret, so this backend mints its join +token and dispatches the worker. Run it alongside `livekit_worker.py`. +""" + +from __future__ import annotations + +import json +import uuid + +from livekit import api +import uvicorn + +from .._common import APP_NAME +from .._common import livekit_credentials +from .._web import make_app + +app = make_app() + + +@app.get("/token") +async def token(room: str | None = None, identity: str | None = None) -> dict: + """Mints a join token and dispatches the ADK worker into the room. + + Args: + room: The room to join, which also becomes the ADK session_id. + identity: The participant identity, reused as the ADK user_id. + + Returns: + The LiveKit server URL and a join token for the browser. + """ + livekit_url, api_key, api_secret = livekit_credentials() + + room = room or f"roll-dice-{uuid.uuid4().hex[:8]}" + identity = identity or f"caller-{uuid.uuid4().hex[:8]}" + + # LiveKit has no user_id / session_id; pass them as job metadata. + metadata = json.dumps({"user_id": identity, "session_id": room}) + + async with api.LiveKitAPI( + url=livekit_url, api_key=api_key, api_secret=api_secret + ) as lkapi: + await lkapi.agent_dispatch.create_dispatch( + api.CreateAgentDispatchRequest( + agent_name=APP_NAME, room=room, metadata=metadata + ) + ) + + join_token = ( + api.AccessToken(api_key, api_secret) + .with_identity(identity) + .with_grants(api.VideoGrants(room_join=True, room=room)) + .to_jwt() + ) + + return {"url": livekit_url, "token": join_token, "room": room} + + +if __name__ == "__main__": + uvicorn.run(app, host="127.0.0.1", port=8080) diff --git a/contributing/samples/integrations/livekit_agent/client/static/index.html b/contributing/samples/integrations/livekit_agent/client/static/index.html new file mode 100644 index 00000000000..9205d41d1d2 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/client/static/index.html @@ -0,0 +1,88 @@ + + + + + + Voice agent + + + + + +
+
+
+

Voice agent

+

+ Speak, type, or turn on your camera. The agent rolls dice and + checks whether the results are prime. Each function it calls + appears on the right as it runs. +

+
+ + + + +
+ + Idle +
+ + + + + + + + +
+

+ Try: "roll a 20 sided die and tell me if it's prime." +

+
+ + +
+ + +
+
+ + +
+ + + + diff --git a/contributing/samples/integrations/livekit_agent/client/static/script.js b/contributing/samples/integrations/livekit_agent/client/static/script.js new file mode 100644 index 00000000000..d0ba1ad08d2 --- /dev/null +++ b/contributing/samples/integrations/livekit_agent/client/static/script.js @@ -0,0 +1,362 @@ +// Copyright 2026 Google LLC +// +// 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. + +// Browser client for the ADK x LiveKit dice agent, on LiveKit's own client SDK. +// Everything but `handleAdkData` rides LiveKit's standard channels, so this +// file works against any LiveKit agent. + +const { Room, RoomEvent, Track } = LivekitClient; + +// LiveKit's own channels. Every LiveKit client SDK speaks these. +const TRANSCRIPTION_TOPIC = 'lk.transcription'; +const CHAT_TOPIC = 'lk.chat'; +const AGENT_STATE_ATTRIBUTE = 'lk.agent.state'; + +// Must match `DATA_TOPIC` in google.adk.integrations.livekit. +const ADK_TOPIC = 'adk'; + +const talkBtn = document.getElementById('talk'); +const cameraBtn = document.getElementById('camera'); +const cameraRow = document.getElementById('camera-row'); +const previewEl = document.getElementById('preview'); +const stateEl = document.getElementById('state'); +const dotEl = document.getElementById('dot'); +const logEl = document.getElementById('log'); +const audioContainer = document.getElementById('audio-container'); +const transcriptEl = document.getElementById('transcript'); +const sayForm = document.getElementById('say-form'); +const sayInput = document.getElementById('say'); +const sendBtn = document.getElementById('send'); +const toolsEl = document.getElementById('tools'); +const toolsEmptyEl = document.getElementById('tools-empty'); + +const decoder = new TextDecoder(); + +let room = null; +// Keyed by LiveKit segment id, so a final caption replaces its interim. +const captions = new Map(); +// Keyed by ADK function call id, so a result lands on the call it answers. +const toolCalls = new Map(); + +// Connection and error messages only; tool activity has its own panel. +function log(message) { + const time = new Date().toLocaleTimeString(); + logEl.textContent += `[${time}] ${message}\n`; + logEl.scrollTop = logEl.scrollHeight; +} + +function setState(label, connected) { + stateEl.textContent = label; + dotEl.classList.toggle('connected', Boolean(connected)); +} + +function setAgentState(agentState) { + stateEl.textContent = `Agent is ${agentState}`; + dotEl.classList.remove('speaking', 'thinking'); + if (agentState === 'speaking' || agentState === 'thinking') { + dotEl.classList.add(agentState); + } +} + +// Asked of the participant, not inferred from "not me", which breaks as soon +// as a third party joins the room. +function isAgentIdentity(identity) { + return Boolean(room?.getParticipantByIdentity(identity)?.isAgent); +} + +function newTranscriptLine(isAgent) { + transcriptEl.querySelector('.placeholder')?.remove(); + const line = document.createElement('p'); + line.className = `line line-${isAgent ? 'agent' : 'user'}`; + transcriptEl.appendChild(line); + return line; +} + +// Each utterance arrives as an interim stream then a final one, sharing a +// segment id. `lk.transcription_final` says which is which. +async function handleTranscription(reader, participantInfo) { + const { attributes } = reader.info; + const segmentId = attributes['lk.segment_id'] ?? reader.info.id; + const isAgent = isAgentIdentity(participantInfo.identity); + + let line = captions.get(segmentId); + if (!line) { + line = newTranscriptLine(isAgent); + captions.set(segmentId, line); + } + + // The final stream carries the whole utterance, so replace rather than + // append: draining it as chunks would render the text twice. + if (attributes['lk.transcription_final'] === 'true') { + line.textContent = await reader.readAll(); + captions.delete(segmentId); + } else { + for await (const chunk of reader) { + line.textContent += chunk; + transcriptEl.scrollTop = transcriptEl.scrollHeight; + } + } + transcriptEl.scrollTop = transcriptEl.scrollHeight; +} + +// Typed turns and phone keypad entries. Our own are echoed locally on send, +// since LiveKit does not deliver a stream back to its sender. +async function handleChat(reader, participantInfo) { + if (participantInfo.identity === room?.localParticipant?.identity) { + return; + } + const text = await reader.readAll(); + if (text) { + newTranscriptLine(isAgentIdentity(participantInfo.identity)).textContent = + text; + transcriptEl.scrollTop = transcriptEl.scrollHeight; + } +} + +// listening / thinking / speaking, the same attribute LiveKit's React +// components read. +function handleAgentState(changed) { + const state = changed[AGENT_STATE_ATTRIBUTE]; + if (state) { + setAgentState(state); + } +} + +// The only ADK-specific handler. Tool calls arrive as JSON: +// {type, id, name, args|response}. Pair the two halves on `id`, not `name`, +// which collides once a tool is called twice in a turn. +function handleAdkData(payload, _participant, _kind, topic) { + if (topic !== ADK_TOPIC) { + return; + } + let message; + try { + message = JSON.parse(decoder.decode(payload)); + } catch (err) { + log(`Ignoring unparseable data message: ${err}`); + return; + } + if (message.type === 'function_call') { + addToolCall(message); + } else if (message.type === 'function_response') { + resolveToolCall(message); + } +} + +function toolKey(message) { + return message.id ?? `${message.name}:no-id`; +} + +function addToolCall(message) { + toolsEmptyEl.hidden = true; + + const row = document.createElement('li'); + row.className = 'tool tool-pending'; + row.innerHTML = ` +
+ + calling… +
+

+    `;
+  // textContent, not innerHTML: tool names and arguments come from the model.
+  row.querySelector('.tool-name').textContent = message.name;
+  row.querySelector('.tool-args').textContent = formatJson(message.args);
+
+  toolsEl.appendChild(row);
+  toolCalls.set(toolKey(message), row);
+  toolsEl.scrollTop = toolsEl.scrollHeight;
+}
+
+function resolveToolCall(message) {
+  const row = toolCalls.get(toolKey(message));
+  if (!row) {
+    // No call on screen: page opened mid-session. Show it rather than drop it.
+    addToolCall({ ...message, args: undefined });
+    return resolveToolCall(message);
+  }
+  toolCalls.delete(toolKey(message));
+
+  row.classList.remove('tool-pending');
+  row.classList.add('tool-done');
+  row.querySelector('.tool-status').textContent = 'returned';
+
+  const result = row.querySelector('.tool-result');
+  result.textContent = formatJson(message.response);
+  result.hidden = false;
+  toolsEl.scrollTop = toolsEl.scrollHeight;
+}
+
+// Compact when it fits on a line, indented when it does not.
+function formatJson(value) {
+  if (value === undefined) {
+    return '';
+  }
+  try {
+    const compact = JSON.stringify(value);
+    return compact.length <= 56 ? compact : JSON.stringify(value, null, 2);
+  } catch (err) {
+    return String(value);
+  }
+}
+
+async function connect() {
+  talkBtn.disabled = true;
+  setState('Connecting…', false);
+  log('Requesting a room and an access token…');
+
+  const resp = await fetch('/token');
+  if (!resp.ok) {
+    setState('Error', false);
+    log(`Token request failed: ${resp.status} ${await resp.text()}`);
+    talkBtn.disabled = false;
+    return;
+  }
+  const { url, token, room: roomName } = await resp.json();
+  log(`Joining room "${roomName}"…`);
+
+  room = new Room();
+
+  room.on(RoomEvent.TrackSubscribed, (track) => {
+    if (track.kind === Track.Kind.Audio) {
+      log('Agent audio connected.');
+      audioContainer.appendChild(track.attach());
+    }
+  });
+
+  room.on(RoomEvent.ParticipantAttributesChanged, handleAgentState);
+  room.on(RoomEvent.DataReceived, handleAdkData);
+
+  room.on(RoomEvent.ParticipantConnected, (participant) => {
+    log(`Participant joined: ${participant.identity}`);
+  });
+
+  room.on(RoomEvent.Disconnected, () => {
+    setState('Call ended', false);
+    dotEl.classList.remove('speaking', 'thinking');
+    log('Disconnected from the room.');
+    captions.clear();
+    toolCalls.clear();
+    talkBtn.disabled = false;
+    talkBtn.textContent = 'Start talking';
+    sayInput.disabled = true;
+    sendBtn.disabled = true;
+    cameraRow.hidden = true;
+    previewEl.hidden = true;
+    cameraBtn.textContent = 'Turn on camera';
+  });
+
+  // Both arrive as text streams, so register before connecting.
+  room.registerTextStreamHandler(TRANSCRIPTION_TOPIC, handleTranscription);
+  room.registerTextStreamHandler(CHAT_TOPIC, handleChat);
+
+  await room.connect(url, token);
+  setState('Connected', true);
+  log('Connected. Enabling microphone…');
+
+  await room.localParticipant.setMicrophoneEnabled(true);
+  setState('Ready', true);
+  log('Microphone live. Speak, or type below.');
+
+  talkBtn.textContent = 'Hang up';
+  talkBtn.disabled = false;
+  sayInput.disabled = false;
+  sendBtn.disabled = false;
+  // Offered, not enabled: most of this demo needs no camera permission.
+  cameraRow.hidden = false;
+}
+
+// Publishing the track is all the client does; the connector samples it.
+async function toggleCamera() {
+  if (!room) {
+    return;
+  }
+  cameraBtn.disabled = true;
+  const turningOn = !room.localParticipant.isCameraEnabled;
+  try {
+    await room.localParticipant.setCameraEnabled(turningOn);
+    showPreview(turningOn);
+    cameraBtn.textContent = turningOn ? 'Turn off camera' : 'Turn on camera';
+    log(
+      turningOn
+        ? 'Camera on. Try asking what the agent can see.'
+        : 'Camera off.',
+    );
+  } catch (err) {
+    // Denied permission or no device. Neither should kill the call.
+    log(`Could not toggle the camera: ${err && err.message ? err.message : err}`);
+  } finally {
+    cameraBtn.disabled = false;
+  }
+}
+
+// A local preview, so you can see what the agent is being sent.
+function showPreview(visible) {
+  const publication = room?.localParticipant?.getTrackPublication(
+    Track.Source.Camera,
+  );
+  const track = publication?.videoTrack;
+  if (visible && track) {
+    track.attach(previewEl);
+    previewEl.hidden = false;
+  } else {
+    if (track) {
+      track.detach(previewEl);
+    }
+    previewEl.hidden = true;
+  }
+}
+
+async function disconnect() {
+  if (room) {
+    await room.disconnect();
+    room = null;
+  }
+}
+
+sayForm.addEventListener('submit', async (event) => {
+  event.preventDefault();
+  const text = sayInput.value.trim();
+  if (!text || !room) {
+    return;
+  }
+  sayInput.value = '';
+  try {
+    // LiveKit's standard chat channel; no ADK-specific encoding needed.
+    await room.localParticipant.sendText(text, { topic: CHAT_TOPIC });
+  } catch (err) {
+    // Not echoed to the transcript, because the agent never received it.
+    log(`Could not send "${text}": ${err && err.message ? err.message : err}`);
+    sayInput.value = text;
+    return;
+  }
+  newTranscriptLine(false).textContent = text;
+  transcriptEl.scrollTop = transcriptEl.scrollHeight;
+});
+
+cameraBtn.addEventListener('click', toggleCamera);
+
+talkBtn.addEventListener('click', async () => {
+  if (room) {
+    await disconnect();
+  } else {
+    try {
+      await connect();
+    } catch (err) {
+      setState('Error', false);
+      log(`Error: ${err && err.message ? err.message : err}`);
+      talkBtn.disabled = false;
+    }
+  }
+});
diff --git a/contributing/samples/integrations/livekit_agent/client/static/style.css b/contributing/samples/integrations/livekit_agent/client/static/style.css
new file mode 100644
index 00000000000..d905f1b7685
--- /dev/null
+++ b/contributing/samples/integrations/livekit_agent/client/static/style.css
@@ -0,0 +1,384 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * 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.
+ */
+
+:root {
+  --bg: #0f1115;
+  --card: #1a1d24;
+  --sunken: #0c0e12;
+  --line: #262a33;
+  --fg: #e7e9ee;
+  --muted: #9aa0ac;
+  --accent: #4f8cff;
+  --ok: #35c46b;
+  --warm: #f0a742;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+/* A class that sets `display` outranks the UA rule behind `hidden`. */
+[hidden] {
+  display: none !important;
+}
+
+body {
+  margin: 0;
+  min-height: 100dvh;
+  display: flex;
+  justify-content: center;
+  padding: 20px;
+  background: var(--bg);
+  color: var(--fg);
+  font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+}
+
+/* Panes are fixed to the viewport and scroll their own overflow, so a long
+ * conversation never pushes the controls off the bottom of the page. */
+.app {
+  width: min(1200px, 100%);
+  height: min(880px, calc(100dvh - 40px));
+  display: grid;
+  grid-template-columns: minmax(0, 1.15fr) minmax(340px, 0.85fr);
+  gap: 18px;
+}
+
+.pane,
+.tools,
+.log-wrap {
+  min-height: 0; /* Lets a grid/flex child shrink so its overflow scrolls. */
+  display: flex;
+  flex-direction: column;
+  background: var(--card);
+  border: 1px solid var(--line);
+  border-radius: 14px;
+  padding: 18px;
+}
+
+.pane-side {
+  gap: 16px;
+  padding: 0;
+  background: transparent;
+  border: none;
+}
+
+h1 {
+  margin: 0 0 8px;
+  font-size: 1.45rem;
+}
+
+.subtitle {
+  margin: 0 0 20px;
+  color: var(--muted);
+  font-size: 0.9rem;
+  line-height: 1.55;
+}
+
+.panel-title {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  margin: 0;
+  font-size: 0.95rem;
+}
+
+/* --- Controls ------------------------------------------------------------ */
+
+.btn {
+  width: 100%;
+  padding: 13px 18px;
+  font-size: 1rem;
+  font-weight: 600;
+  color: white;
+  background: var(--accent);
+  border: none;
+  border-radius: 10px;
+  cursor: pointer;
+}
+
+.btn:disabled {
+  opacity: 0.6;
+  cursor: default;
+}
+
+.btn-secondary {
+  width: auto;
+  background: var(--line);
+  font-size: 0.95rem;
+  padding: 11px 18px;
+}
+
+.status {
+  display: flex;
+  align-items: center;
+  gap: 9px;
+  margin: 16px 0 12px;
+  color: var(--muted);
+  font-size: 0.95rem;
+}
+
+/* Agent state, straight off LiveKit's lk.agent.state attribute. */
+.dot {
+  width: 11px;
+  height: 11px;
+  border-radius: 50%;
+  background: var(--muted);
+  flex: none;
+}
+
+.dot.connected {
+  background: var(--ok);
+}
+
+.dot.speaking {
+  background: var(--accent);
+  animation: pulse 1.1s ease-in-out infinite;
+}
+
+.dot.thinking {
+  background: var(--warm);
+  animation: pulse 1.1s ease-in-out infinite;
+}
+
+@keyframes pulse {
+  0%,
+  100% {
+    opacity: 1;
+  }
+  50% {
+    opacity: 0.35;
+  }
+}
+
+/* --- Captions ------------------------------------------------------------ */
+
+.transcript {
+  flex: 1; /* Takes whatever height is left over. */
+  min-height: 120px;
+  margin: 0 0 14px;
+  overflow-y: auto;
+  padding: 6px 14px;
+  background: var(--sunken);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  line-height: 1.6;
+}
+
+.placeholder,
+.tools-empty,
+.tools-hint {
+  color: var(--muted);
+  font-size: 0.85rem;
+}
+
+.line {
+  margin: 10px 0;
+  word-break: break-word;
+}
+
+.line-user::before {
+  content: "You: ";
+  color: var(--muted);
+}
+
+.line-agent {
+  color: var(--accent);
+}
+
+.line-agent::before {
+  content: "Agent: ";
+  color: var(--muted);
+}
+
+/* --- Text input ---------------------------------------------------------- */
+
+.say {
+  display: flex;
+  gap: 8px;
+}
+
+.say-input {
+  flex: 1;
+  padding: 11px 13px;
+  background: var(--sunken);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  color: var(--fg);
+  font: inherit;
+  font-size: 0.95rem;
+}
+
+.say-input:disabled {
+  opacity: 0.6;
+}
+
+/* --- Camera -------------------------------------------------------------- */
+
+.camera {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  margin-bottom: 14px;
+}
+
+.preview {
+  width: 132px;
+  border-radius: 10px;
+  border: 1px solid var(--line);
+  background: var(--sunken);
+  /* Mirrored, so moving left on screen matches moving left in the room. */
+  transform: scaleX(-1);
+}
+
+/* --- Tool activity ------------------------------------------------------- */
+
+.tools {
+  flex: 1; /* Grows to fill the column; the list inside is what scrolls. */
+}
+
+.tools-hint {
+  margin: 6px 0 0;
+  line-height: 1.5;
+}
+
+.tool-list {
+  flex: 1;
+  min-height: 0;
+  margin: 12px 0 0;
+  padding: 0;
+  list-style: none;
+  overflow-y: auto;
+}
+
+.tool-list:empty {
+  flex: none;
+}
+
+.tools-empty {
+  margin: 12px 0 0;
+  font-style: italic;
+}
+
+.tool {
+  padding: 10px 12px;
+  margin-bottom: 8px;
+  border-radius: 8px;
+  border-left: 3px solid var(--line);
+  background: rgba(255, 255, 255, 0.02);
+}
+
+.tool-pending {
+  border-left-color: var(--warm);
+}
+
+.tool-done {
+  border-left-color: var(--ok);
+}
+
+.tool-head {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 10px;
+}
+
+.tool-name,
+.tool-args,
+.tool-result {
+  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+}
+
+.tool-status {
+  flex: none;
+  font-size: 0.75rem;
+  color: var(--muted);
+}
+
+.tool-pending .tool-status {
+  color: var(--warm);
+}
+
+.tool-done .tool-status {
+  color: var(--ok);
+}
+
+.tool-args,
+.tool-result {
+  margin: 7px 0 0;
+  font-size: 0.8rem;
+  line-height: 1.45;
+  white-space: pre-wrap;
+  word-break: break-word;
+  color: var(--muted);
+}
+
+.tool-args:empty {
+  display: none;
+}
+
+/* Separated from the arguments above it, or the two read as one blob. */
+.tool-result {
+  padding-top: 7px;
+  border-top: 1px dashed var(--line);
+  color: var(--ok);
+}
+
+/* --- Connection log ------------------------------------------------------ */
+
+.log-wrap {
+  flex: 0 1 34%;
+  min-height: 128px;
+}
+
+.log {
+  flex: 1;
+  min-height: 0;
+  margin: 10px 0 0;
+  overflow-y: auto;
+  padding: 12px;
+  background: var(--sunken);
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  color: var(--muted);
+  font-size: 0.8rem;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+
+/* --- Narrow screens ------------------------------------------------------ */
+
+/* One column below two comfortable ones: fixed-height panes on a phone give
+ * each a uselessly small scroll area. */
+@media (max-width: 940px) {
+  .app {
+    width: min(680px, 100%);
+    height: auto;
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  .transcript {
+    max-height: 320px;
+  }
+
+  .tool-list {
+    max-height: 300px;
+  }
+
+  .log {
+    max-height: 200px;
+  }
+}
diff --git a/contributing/samples/integrations/livekit_agent/livekit_worker.py b/contributing/samples/integrations/livekit_agent/livekit_worker.py
new file mode 100644
index 00000000000..614979e80f0
--- /dev/null
+++ b/contributing/samples/integrations/livekit_agent/livekit_worker.py
@@ -0,0 +1,53 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Runs the dice agent as a LiveKit worker, one job per call. See README.md."""
+
+from __future__ import annotations
+
+import json
+
+from google.adk.integrations.livekit import LiveKitRunner
+from google.adk.runners import InMemoryRunner
+from livekit.agents import AgentServer
+from livekit.agents import cli
+from livekit.agents import JobContext
+
+from ._common import APP_NAME
+from .agent import root_agent
+
+runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
+
+server = AgentServer()
+
+
+# Drop `agent_name` to auto-dispatch this agent into every room.
+@server.rtc_session(agent_name=APP_NAME)
+async def entrypoint(ctx: JobContext) -> None:
+  """Bridges one dispatched call into the ADK agent."""
+  await ctx.connect()
+
+  # ADK's ids ride in the job metadata; see client/main.py.
+  meta = json.loads(ctx.job.metadata or "{}")
+
+  await LiveKitRunner(
+      runner=runner,
+      room=ctx.room,
+      user_id=meta.get("user_id", "live-user"),
+      session_id=meta.get("session_id", ctx.room.name),
+  ).start()
+
+
+if __name__ == "__main__":
+  cli.run_app(server)
diff --git a/contributing/samples/integrations/livekit_agent/quickstart.py b/contributing/samples/integrations/livekit_agent/quickstart.py
new file mode 100644
index 00000000000..7c065e594e1
--- /dev/null
+++ b/contributing/samples/integrations/livekit_agent/quickstart.py
@@ -0,0 +1,117 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Talk to the dice agent over LiveKit, in one process.
+
+Mints a token for the browser and joins the ADK agent into the same room. See
+`livekit_worker.py` to scale calls independently of this process.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import logging
+import uuid
+
+from fastapi import FastAPI
+from google.adk.integrations.livekit import LiveKitRunner
+from google.adk.runners import InMemoryRunner
+from livekit import api
+from livekit import rtc
+import uvicorn
+
+from ._common import APP_NAME
+from ._common import livekit_credentials
+from ._web import make_app
+from .agent import root_agent
+
+logger = logging.getLogger("google_adk." + __name__)
+
+runner = InMemoryRunner(agent=root_agent, app_name=APP_NAME)
+
+# Referenced so a live session is not garbage collected mid-call.
+_sessions: set[asyncio.Task[None]] = set()
+
+
+@contextlib.asynccontextmanager
+async def _lifespan(app: FastAPI):
+  """Ends in-flight calls when the process goes down."""
+  yield
+  for task in list(_sessions):
+    task.cancel()
+    with contextlib.suppress(asyncio.CancelledError):
+      await task
+
+
+app = make_app(lifespan=_lifespan)
+
+
+async def _run_agent(room_name: str, user_id: str) -> None:
+  """Joins the agent into `room_name` and bridges it until the call ends."""
+  livekit_url, api_key, api_secret = livekit_credentials()
+
+  # Both grants are required: without them LiveKit's voice-assistant
+  # components ignore the agent and silently drop its `lk.agent.state`.
+  agent_token = (
+      api.AccessToken(api_key, api_secret)
+      .with_identity(f"adk-agent-{room_name}")
+      .with_kind("agent")
+      .with_grants(
+          api.VideoGrants(
+              room_join=True, room=room_name, can_update_own_metadata=True
+          )
+      )
+      .to_jwt()
+  )
+
+  room = rtc.Room()
+  await room.connect(livekit_url, agent_token)
+  logger.info("agent joined room %s", room_name)
+  try:
+    await LiveKitRunner(
+        runner=runner,
+        room=room,
+        user_id=user_id,
+        session_id=room_name,  # One room is one conversation.
+    ).start()
+  finally:
+    await room.disconnect()
+    logger.info("agent left room %s", room_name)
+
+
+@app.get("/token")
+async def token() -> dict:
+  """Mints a join token for the browser and puts the agent in the room."""
+  livekit_url, api_key, api_secret = livekit_credentials()
+
+  room_name = f"roll-dice-{uuid.uuid4().hex[:8]}"
+  identity = f"caller-{uuid.uuid4().hex[:8]}"
+
+  task = asyncio.create_task(_run_agent(room_name, identity))
+  _sessions.add(task)
+  task.add_done_callback(_sessions.discard)
+
+  join_token = (
+      api.AccessToken(api_key, api_secret)
+      .with_identity(identity)
+      .with_grants(api.VideoGrants(room_join=True, room=room_name))
+      .to_jwt()
+  )
+  return {"url": livekit_url, "token": join_token, "room": room_name}
+
+
+if __name__ == "__main__":
+  logging.basicConfig(level=logging.INFO)
+  uvicorn.run(app, host="127.0.0.1", port=8080)
diff --git a/pyproject.toml b/pyproject.toml
index 46edcbc6949..4443fa491ea 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -103,6 +103,8 @@ optional-dependencies.all = [
   "langgraph>=1.0.10,<2",
   "langgraph-checkpoint>=4.1.1,<5",
   "litellm>=1.84",
+  "livekit>=1,<2",
+  "livekit-api>=1,<2",
   "llama-index-embeddings-google-genai>=0.3",
   "llama-index-readers-file>=0.4",
   "lxml>=5.3",
@@ -120,6 +122,7 @@ optional-dependencies.all = [
   "opentelemetry-instrumentation-httpx>=0.54b0,<1",
   "opentelemetry-resourcedetector-gcp>=1.9.0a0,<2",
   "pandas>=2.2.3",
+  "pillow>=10.3",
   "protobuf>=6",
   "pyarrow>=14",
   "pypika>=0.50",
@@ -243,6 +246,11 @@ optional-dependencies.gcp = [
   "opentelemetry-resourcedetector-gcp>=1.9.0a0,<2",
   "python-dateutil>=2.9.0.post0,<3",
 ]
+optional-dependencies.livekit = [
+  "livekit>=1,<2",     # The media SDK (rtc rooms/tracks), not the agent framework.
+  "livekit-api>=1,<2", # Server API; only the call transfer tool needs it.
+  "pillow>=10.3",      # JPEG-encodes inbound video frames.
+]
 optional-dependencies.mcp = [
   "anyio>=4.9,<5",
   "mcp>=1.24,<2",
@@ -295,6 +303,8 @@ optional-dependencies.test = [
   "langgraph>=1.0.10,<2",
   "langgraph-checkpoint>=4.1.1,<5",                                  # LangGraph's own floor still admits unsafe checkpoint deserialization.
   "litellm>=1.84",
+  "livekit>=1,<2",
+  "livekit-api>=1,<2",
   "llama-index-readers-file>=0.4",
   "lxml>=5.3",
   "mcp>=1.24,<2",
@@ -308,6 +318,7 @@ optional-dependencies.test = [
   "opentelemetry-instrumentation-google-genai>=0.7b1,<1",
   "opentelemetry-resourcedetector-gcp>=1.9.0a0,<2",
   "pandas>=2.2.3",
+  "pillow>=10.3",
   "protobuf>=6",
   "pyarrow>=14",
   "pypika>=0.50",
diff --git a/src/google/adk/features/_feature_registry.py b/src/google/adk/features/_feature_registry.py
index ab0ecc99809..0bf179ba971 100644
--- a/src/google/adk/features/_feature_registry.py
+++ b/src/google/adk/features/_feature_registry.py
@@ -49,6 +49,7 @@ class FeatureName(str, Enum):
   GOOGLE_CREDENTIALS_CONFIG = "GOOGLE_CREDENTIALS_CONFIG"
   GOOGLE_TOOL = "GOOGLE_TOOL"
   JSON_SCHEMA_FOR_FUNC_DECL = "JSON_SCHEMA_FOR_FUNC_DECL"
+  LIVEKIT = "LIVEKIT"
   MCP_AGENT_SERVER = "MCP_AGENT_SERVER"
   # Private (leading underscore): not part of the public API surface.
   # GE flips this on by setting the env var
@@ -170,6 +171,9 @@ class FeatureConfig:
     FeatureName.JSON_SCHEMA_FOR_FUNC_DECL: FeatureConfig(
         FeatureStage.EXPERIMENTAL, default_on=True
     ),
+    FeatureName.LIVEKIT: FeatureConfig(
+        FeatureStage.EXPERIMENTAL, default_on=True
+    ),
     FeatureName.MCP_AGENT_SERVER: FeatureConfig(
         FeatureStage.EXPERIMENTAL, default_on=True
     ),
diff --git a/src/google/adk/integrations/livekit/README.md b/src/google/adk/integrations/livekit/README.md
new file mode 100644
index 00000000000..7c6c3dc6fea
--- /dev/null
+++ b/src/google/adk/integrations/livekit/README.md
@@ -0,0 +1,234 @@
+# LiveKit Integration
+
+`LiveKitRunner` puts an unmodified ADK live agent into a
+[LiveKit](https://livekit.io/) room: WebRTC for web and mobile, SIP for
+telephony, and LiveKit's client SDKs for iOS, Android, Flutter and Unity.
+
+It is a bridge over the transport-agnostic
+`LiveRequestQueue` -> `run_live()` -> `Event` contract, with no agent logic, no
+codecs and no signaling. It depends on LiveKit's media SDK (`livekit`), not on
+their agent framework.
+
+This integration is experimental; its API may change.
+
+## Prerequisites
+
+```bash
+pip install "google-adk[livekit]"
+```
+
+Set `LIVEKIT_URL`, `LIVEKIT_API_KEY` and `LIVEKIT_API_SECRET`, plus your model
+credentials for a native-audio live model.
+
+## Usage
+
+`LiveKitRunner` takes an already-connected `rtc.Room`, so it does not care how
+the room was joined. `start()` returns when the caller hangs up, the room
+closes, a tool calls `end_call`, or `run_live` finishes.
+
+### A room you join yourself
+
+```python
+from google.adk.integrations.livekit import LiveKitRunner
+
+room = rtc.Room()
+await room.connect(livekit_url, agent_token)
+
+await LiveKitRunner(
+    runner=runner,          # an unmodified ADK Runner
+    room=room,
+    user_id="alice",
+    session_id=room_name,   # one room is one conversation
+).start()
+```
+
+Mint `agent_token` with `with_kind("agent")`, so LiveKit's voice-assistant
+components recognize the participant, and with `can_update_own_metadata` in its
+`VideoGrants`, so it can publish `lk.agent.state`. Without the latter,
+`set_attributes` succeeds and the server silently drops the update. Dispatch
+sets both for you on the worker path.
+
+### A worker LiveKit dispatches per call
+
+The production shape, and the one telephony uses. Requires
+`pip install "livekit-agents>=1.4"`.
+
+```python
+@server.rtc_session(agent_name="my_app")
+async def entrypoint(ctx: JobContext) -> None:
+    await ctx.connect()
+    meta = json.loads(ctx.job.metadata or "{}")
+    await LiveKitRunner(
+        runner=runner,
+        room=ctx.room,
+        user_id=meta.get("user_id", "live-user"),
+        session_id=meta.get("session_id", ctx.room.name),
+    ).start()
+```
+
+LiveKit has no `user_id` / `session_id`, so pass ADK's ids through the job
+metadata. A room is normally a new conversation and `Runner` does not create
+sessions, so `LiveKitRunner` creates one if it is missing; pass
+`create_session=False` to require one created out of band.
+
+`start()` waits up to 30 seconds for a caller to join before opening the model
+connection, because a live connection fixes its tool declarations when it
+opens and `LiveKitToolset` reads the room to decide what to offer. Set
+`wait_for_participant` to change the timeout, or `None` to start at once.
+
+## What clients see
+
+The connector publishes on LiveKit's standard channels, so their client SDKs
+and prebuilt components render an ADK agent without ADK-specific client code.
+
+| Concern | Channel | Rendered by |
+| :- | :- | :- |
+| Agent speech | Published audio track | every client SDK |
+| Captions, both sides | `lk.transcription` text streams | `useTranscriptions`, Agents Playground |
+| Typed input, and phone keypad entries | `lk.chat` text stream | React `Chat`, `useChat` |
+| Listening / thinking / speaking | `lk.agent.state` attribute | `useVoiceAssistant`, `BarVisualizer` |
+| Tool call / result | `adk` data topic (JSON) | nothing; see below |
+
+Transcriptions follow LiveKit's interim/final segment model, carrying
+`lk.segment_id`, `lk.transcription_final` and `lk.transcribed_track_id`. The
+caller's words are published under the caller's identity, so a client can tell
+the two speakers apart.
+
+A full transcript is a merge of `lk.transcription` and `lk.chat`, as in
+LiveKit's own `useChatAndTranscription`: nothing transcribes a typed turn or a
+keypress, so those only ever reach the chat topic.
+
+Tool activity has no LiveKit convention, so it goes out on an `adk` data
+topic that clients may ignore:
+
+```json
+{"type": "function_call",     "id": "...", "name": "roll_die", "args": {"sides": 20}}
+{"type": "function_response", "id": "...", "name": "roll_die", "response": {"result": 19}}
+```
+
+Pair a result to its call on `id`, not on `name`, which collides as soon as
+one tool is called twice in a turn. Text sent to that topic as
+`{"type": "text", "text": ...}` is still accepted as a user turn.
+
+## What clients send
+
+Inbound needs no ADK-specific code either: publish a microphone track to
+speak, `lk.chat` to type, and a camera track to be seen. The connector
+subscribes to any video track, samples it at 1 fps and sends JPEG frames to
+the model, so vision costs the agent an instruction rather than any code.
+
+## Acting on the call from a tool
+
+The prebuilt call tools ship as a toolset, which goes in an agent's `tools`
+list like any other:
+
+```python
+from google.adk.integrations.livekit import LiveKitToolset
+
+root_agent = Agent(
+    model="gemini-live-2.5-flash-native-audio",
+    instruction="...",
+    tools=[check_line_status, LiveKitToolset()],
+)
+```
+
+It resolves per invocation, so the model is never offered a tool the current
+call cannot honor:
+
+| Call | Tools offered |
+| :- | :- |
+| None, such as under `adk web` | nothing; the agent runs unchanged |
+| WebRTC | `end_call` |
+| SIP | `end_call`, `transfer_call`, `send_dtmf` |
+
+`LiveKitToolset` takes `BaseToolset`'s usual arguments, so pass
+`tool_filter=["transfer_call", "send_dtmf"]` for an agent that should never
+decide the conversation is over. The three tools are also importable
+individually if you would rather list them yourself.
+
+### Your own tools
+
+`Runner.run_live()` takes ids and a queue, so a tool has no parameter through
+which to reach the room. The connector publishes the call on a context
+variable that tools read with `current_call()`:
+
+```python
+from google.adk.integrations.livekit import current_call
+
+async def open_the_door(door_id: str) -> str:
+  """Opens a door in the game world."""
+  return await current_call().perform_rpc(method="open_door", payload=door_id)
+```
+
+`LiveKitCall` exposes `room`, `caller_phone_number`, `sip_attributes()`,
+`send_dtmf()`, `send_data()`, `perform_rpc()`, `transfer()` and
+`await hang_up()`. Use `perform_rpc()` when the client owns the outcome and its
+answer matters, since the return value becomes the tool result; use
+`send_data()` to broadcast when nothing needs to come back. `hang_up()` deletes
+the room on a SIP call, to drop the phone leg rather than leave the caller on
+an open line.
+
+Outside a LiveKit session `current_call()` raises, so a tool of your own
+belongs where you wire the transport unless it can tolerate that. The toolset
+has no such constraint.
+
+## Telephony
+
+A SIP participant is an ordinary LiveKit participant, so an inbound PSTN call
+reaches an ADK agent once an
+[inbound trunk](https://docs.livekit.io/telephony/accepting-calls/inbound-trunk/)
+and [dispatch rule](https://docs.livekit.io/telephony/accepting-calls/dispatch-rule/)
+point at your worker's `agent_name`. The connector adds:
+
+- **Caller identity in session state**: `livekit_caller_phone_number`,
+  `livekit_called_phone_number`, `livekit_sip_call_id`,
+  `livekit_is_phone_call` and the raw `livekit_sip_attributes`. Attributes
+  mapped from `X-*` SIP headers arrive asynchronously and are applied as a
+  state delta when they land.
+- **Inbound DTMF**, buffered into one turn on `#` or after 1.5 seconds idle,
+  so a six-digit account number is one input rather than six interruptions.
+  The digits also go out on `lk.chat` under the caller's identity, since
+  LiveKit relays the tones but not the turn this connector assembles from
+  them.
+- **A cold transfer** via `TransferSIPParticipant`.
+- **A hangup that drops the phone leg**, by deleting the room, rather than
+  leaving the caller on an open line.
+
+Only WebRTC is verified end to end. The telephony paths are unit-tested but
+have not been run against a real trunk; a softphone against a LiveKit inbound
+trunk with `"numbers": []` exercises them without a phone number.
+
+## Run config
+
+The default is audio out, captions both ways, and session resumption:
+
+```python
+run_config = RunConfig(
+    response_modalities=[types.Modality.AUDIO],
+    input_audio_transcription=types.AudioTranscriptionConfig(),
+    output_audio_transcription=types.AudioTranscriptionConfig(),
+    session_resumption=types.SessionResumptionConfig(),
+)
+```
+
+Your own `run_config` replaces that wholesale, so copy the lines you still
+want. Without `session_resumption` a reconnect loses the conversation, since
+the history ADK replays is assembled before the call. Without the two
+transcription configs `lk.transcription` is silent, since ADK enables them by
+itself only for an agent with `sub_agents`.
+
+A `Workflow` root also works, opening one model connection per stage. Expect a
+gap of about a second at each handoff.
+
+## Production notes
+
+- **Use a durable session service.** Under dispatch each job runs in its own
+  process, so `InMemoryRunner` is per-call and nothing is shared or persisted.
+- **Long calls accumulate input audio.** ADK caches inbound audio for the life
+  of an invocation and only drains it when `RunConfig.save_live_blob` is set.
+- **Outbound audio** is captured onto a 24 kHz track by a dedicated playback
+  task, which is what lets barge-in drop unplayed speech immediately.
+
+See
+[`contributing/samples/integrations/livekit_agent`](../../../../../contributing/samples/integrations/livekit_agent/README.md)
+for a runnable sample of both topologies.
diff --git a/src/google/adk/integrations/livekit/__init__.py b/src/google/adk/integrations/livekit/__init__.py
new file mode 100644
index 00000000000..3568dedda49
--- /dev/null
+++ b/src/google/adk/integrations/livekit/__init__.py
@@ -0,0 +1,64 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""LiveKit integration.
+
+Bridges a LiveKit room to an ADK live agent, giving an unmodified agent
+telephony (SIP/PSTN), WebRTC, and Unity/gaming ingress. Install with:
+pip install "google-adk[livekit]"
+"""
+
+from __future__ import annotations
+
+import typing
+
+if typing.TYPE_CHECKING:
+  from ._call_context import current_call
+  from ._call_context import LiveKitCall
+  from ._call_tools import end_call
+  from ._call_tools import send_dtmf
+  from ._call_tools import transfer_call
+  from ._call_toolset import LiveKitToolset
+  from ._livekit_runner import DATA_TOPIC
+  from ._livekit_runner import LiveKitRunner
+  from ._livekit_runner import LK_CHAT_TOPIC
+  from ._transcripts import LK_TRANSCRIPTION_TOPIC
+
+_lazy_imports = {
+    "DATA_TOPIC": "._livekit_runner",
+    "LK_CHAT_TOPIC": "._livekit_runner",
+    "LK_TRANSCRIPTION_TOPIC": "._transcripts",
+    "LiveKitCall": "._call_context",
+    "LiveKitRunner": "._livekit_runner",
+    "LiveKitToolset": "._call_toolset",
+    "current_call": "._call_context",
+    "end_call": "._call_tools",
+    "send_dtmf": "._call_tools",
+    "transfer_call": "._call_tools",
+}
+
+__all__ = sorted(_lazy_imports)
+
+
+def __getattr__(name: str) -> typing.Any:
+  if name in _lazy_imports:
+    import importlib
+
+    module = importlib.import_module(_lazy_imports[name], __name__)
+    return getattr(module, name)
+  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
+
+def __dir__() -> list[str]:
+  return list(__all__)
diff --git a/src/google/adk/integrations/livekit/_call_context.py b/src/google/adk/integrations/livekit/_call_context.py
new file mode 100644
index 00000000000..4fd593de8a9
--- /dev/null
+++ b/src/google/adk/integrations/livekit/_call_context.py
@@ -0,0 +1,309 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Access to the in-progress LiveKit call from inside an ADK tool.
+
+`Runner.run_live()` takes ids and a queue, so a tool has no parameter through
+which to reach the room. `LiveKitRunner` publishes the call on a `ContextVar`
+instead, which ADK copies when it dispatches a tool.
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from collections.abc import Iterator
+import contextlib
+import contextvars
+import logging
+from types import ModuleType
+from typing import Optional
+
+from ._rtc import rtc
+
+logger = logging.getLogger("google_adk." + __name__)
+
+# Set by LiveKit on a telephony caller. Every read is optional: the number is
+# absent when the dispatch rule hides it, and header-mapped attributes arrive
+# asynchronously.
+SIP_PHONE_NUMBER_ATTRIBUTE = "sip.phoneNumber"
+SIP_TRUNK_PHONE_NUMBER_ATTRIBUTE = "sip.trunkPhoneNumber"
+SIP_CALL_ID_ATTRIBUTE = "sip.callID"
+SIP_CALL_STATUS_ATTRIBUTE = "sip.callStatus"
+
+_PARTICIPANT_KIND_SIP = rtc.ParticipantKind.PARTICIPANT_KIND_SIP
+
+
+class LiveKitCall:
+  """The call an ADK tool is currently running inside.
+
+  Obtained with `current_call()`. One instance per `LiveKitRunner`, valid for
+  the life of the call.
+
+  Attributes:
+    room: The LiveKit room backing the call.
+    user_id: The ADK user id for the session.
+    session_id: The ADK session id for the session.
+  """
+
+  def __init__(
+      self,
+      *,
+      room: rtc.Room,
+      user_id: str,
+      session_id: str,
+      hang_up_callback: Callable[[], None],
+  ):
+    self.room = room
+    self.user_id = user_id
+    self.session_id = session_id
+    self._hang_up_callback = hang_up_callback
+
+  @property
+  def sip_participant(self) -> Optional[rtc.RemoteParticipant]:
+    """The telephony caller in the room, or None on a non-SIP call."""
+    for participant in self.room.remote_participants.values():
+      if participant.kind == _PARTICIPANT_KIND_SIP:
+        return participant
+    return None
+
+  @property
+  def caller_phone_number(self) -> Optional[str]:
+    """The number this call came from.
+
+    Returns:
+      The caller's number, or None if this is not a SIP call or the dispatch
+      rule hides the number.
+    """
+    return self.sip_attributes().get(SIP_PHONE_NUMBER_ATTRIBUTE)
+
+  def sip_attributes(self) -> dict[str, str]:
+    """Returns every `sip.*` attribute LiveKit set on the caller.
+
+    Empty when the call did not arrive over SIP.
+    """
+    participant = self.sip_participant
+    if participant is None:
+      return {}
+    return {
+        key: value
+        for key, value in (participant.attributes or {}).items()
+        if key.startswith("sip.")
+    }
+
+  async def send_dtmf(self, digits: str) -> None:
+    """Plays DTMF tones into the call, for navigating a downstream IVR.
+
+    Args:
+      digits: The digits to play, e.g. `"123#"`. Characters outside
+        `0-9*#A-D` are skipped.
+    """
+    for digit in digits:
+      # The code and the digit must agree, so normalize both.
+      key = digit.upper()
+      code = _DTMF_CODES.get(key)
+      if code is None:
+        logger.warning("Skipping non-DTMF character %r.", digit)
+        continue
+      await self.room.local_participant.publish_dtmf(code=code, digit=key)
+
+  async def perform_rpc(
+      self,
+      *,
+      method: str,
+      payload: str,
+      destination_identity: Optional[str] = None,
+      response_timeout: Optional[float] = None,
+  ) -> str:
+    """Calls a method the client registered, and returns what it replied.
+
+    Args:
+      method: The method name the client registered.
+      payload: The request body, as a string.
+      destination_identity: Which participant to call. Defaults to the only
+        remote participant.
+      response_timeout: Seconds to wait for the client's reply.
+
+    Returns:
+      The client's reply.
+
+    Raises:
+      RuntimeError: If `destination_identity` is omitted and the room does not
+        hold exactly one remote participant.
+    """
+    identity = destination_identity or self._sole_remote_identity()
+    return await self.room.local_participant.perform_rpc(
+        destination_identity=identity,
+        method=method,
+        payload=payload,
+        response_timeout=response_timeout,
+    )
+
+  def _sole_remote_identity(self) -> str:
+    identities = list(self.room.remote_participants)
+    if len(identities) != 1:
+      raise RuntimeError(
+          "Cannot infer an RPC destination: the room holds"
+          f" {len(identities)} remote participants. Pass"
+          " destination_identity explicitly."
+      )
+    return identities[0]
+
+  async def send_data(
+      self, payload: bytes, *, topic: str, reliable: bool = True
+  ) -> None:
+    """Publishes an arbitrary payload on the room data track.
+
+    Args:
+      payload: The bytes to publish.
+      topic: The data topic clients filter on.
+      reliable: Whether to send reliably. False trades delivery for latency,
+        which suits high-frequency telemetry.
+    """
+    await self.room.local_participant.publish_data(
+        payload, topic=topic, reliable=reliable
+    )
+
+  async def transfer(self, transfer_to: str) -> None:
+    """Cold-transfers the SIP caller to another number or SIP URI.
+
+    Uses LiveKit's server API, which reads `LIVEKIT_URL`, `LIVEKIT_API_KEY`
+    and `LIVEKIT_API_SECRET` from the environment.
+
+    Args:
+      transfer_to: Destination, as `tel:+15105550100` or a `sip:` URI.
+
+    Raises:
+      RuntimeError: If the call did not arrive over SIP.
+      ImportError: If `livekit-api` is not installed.
+    """
+    participant = self.sip_participant
+    if participant is None:
+      raise RuntimeError(
+          "Cannot transfer: this call has no SIP participant. Transfers apply"
+          " to telephony calls only."
+      )
+    api = _server_api()
+    async with api.LiveKitAPI() as livekit_api:
+      await livekit_api.sip.transfer_sip_participant(
+          api.TransferSIPParticipantRequest(
+              room_name=self.room.name,
+              participant_identity=participant.identity,
+              transfer_to=transfer_to,
+          )
+      )
+
+  async def hang_up(self) -> None:
+    """Ends the call, closing the model connection and leaving the room.
+
+    On a SIP call the room is deleted as well, because the phone leg is held
+    up by the SIP service rather than by a client. The ADK side ends either
+    way, so a room that could not be deleted does not also leave the model
+    connection open.
+
+    Raises:
+      ImportError: If `livekit-api` is not installed and this is a phone call.
+      Exception: Whatever the server API raises if the room cannot be deleted,
+        after the local session has already ended.
+    """
+    try:
+      if self.sip_participant is not None:
+        await self._close_room()
+    finally:
+      self._hang_up_callback()
+
+  async def _close_room(self) -> None:
+    """Deletes the room, disconnecting every participant.
+
+    A room is one call in this model. Use `livekit_api.room.remove_participant`
+    instead if a room of yours outlives the agent.
+    """
+    api = _server_api()
+    async with api.LiveKitAPI() as livekit_api:
+      await livekit_api.room.delete_room(
+          api.DeleteRoomRequest(room=self.room.name)
+      )
+
+
+def _server_api() -> ModuleType:
+  """Returns the `livekit.api` module, which only server-side calls need.
+
+  Imported lazily so an agent that never transfers or hangs up a phone call
+  does not pay for the server SDK.
+
+  Raises:
+    ImportError: If `livekit-api` is not installed.
+  """
+  try:
+    from livekit import api
+  except ImportError as e:
+    raise ImportError(
+        "livekit-api is not installed. It is required for call transfers and"
+        " for ending a phone call. Install it with `pip install"
+        ' "google-adk[livekit]"`.'
+    ) from e
+  return api
+
+
+# RFC 4733 event codes.
+_DTMF_CODES: dict[str, int] = {
+    **{str(digit): digit for digit in range(10)},
+    "*": 10,
+    "#": 11,
+    "A": 12,
+    "B": 13,
+    "C": 14,
+    "D": 15,
+}
+
+_CURRENT_CALL: contextvars.ContextVar[LiveKitCall] = contextvars.ContextVar(
+    "google_adk_livekit_current_call"
+)
+
+
+def _current_call_or_none() -> Optional[LiveKitCall]:
+  """Returns the in-progress call, or None when there is no LiveKit session."""
+  return _CURRENT_CALL.get(None)
+
+
+def current_call() -> LiveKitCall:
+  """Returns the LiveKit call the calling tool is running inside.
+
+  Returns:
+    The in-progress call.
+
+  Raises:
+    RuntimeError: If no LiveKit call is in progress.
+  """
+  call = _CURRENT_CALL.get(None)
+  if call is None:
+    raise RuntimeError(
+        "No LiveKit call is in progress. `current_call()` only works inside an"
+        " agent driven by `LiveKitRunner`; this agent is running without a"
+        " LiveKit transport."
+    )
+  return call
+
+
+@contextlib.contextmanager
+def _use_call(call: LiveKitCall) -> Iterator[None]:
+  """Publishes `call` to tools for the duration of the block.
+
+  Must wrap the task that drives `run_live`, since ADK snapshots the ambient
+  context when it dispatches a tool.
+  """
+  token = _CURRENT_CALL.set(call)
+  try:
+    yield
+  finally:
+    _CURRENT_CALL.reset(token)
diff --git a/src/google/adk/integrations/livekit/_call_tools.py b/src/google/adk/integrations/livekit/_call_tools.py
new file mode 100644
index 00000000000..5a7d195de7f
--- /dev/null
+++ b/src/google/adk/integrations/livekit/_call_tools.py
@@ -0,0 +1,89 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Tools that let a live agent act on the call it is on.
+
+The docstrings below are what the model reads, so they address the model
+rather than the developer.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from ._call_context import current_call
+
+logger = logging.getLogger("google_adk." + __name__)
+
+
+async def end_call() -> str:
+  """Hangs up and ends the conversation.
+
+  Call this once the caller has said goodbye or their request is resolved.
+
+  Returns:
+    A confirmation that the call is ending.
+  """
+  call = current_call()
+  logger.info("Agent ending call for session %s.", call.session_id)
+  try:
+    await call.hang_up()
+  except Exception as e:  # pylint: disable=broad-except
+    # `hang_up` has already ended the ADK side, so report the failure rather
+    # than failing the turn.
+    logger.warning("Hangup for session %s failed: %s", call.session_id, e)
+    return f"Could not hang up cleanly: {e}. Ending the conversation anyway."
+  return "The call is ending."
+
+
+async def transfer_call(transfer_to: str) -> str:
+  """Transfers the caller to another phone number or SIP address.
+
+  Only works on a phone call. Tell the caller they are being transferred
+  before calling this, because the transfer takes effect immediately.
+
+  Args:
+    transfer_to: Where to send the caller, as a phone number in E.164 form
+      (for example `+15105550100`) or a full SIP URI.
+
+  Returns:
+    A confirmation, or an explanation of why the transfer could not happen.
+  """
+  call = current_call()
+  destination = (
+      transfer_to
+      if transfer_to.startswith(("tel:", "sip:"))
+      else f"tel:{transfer_to}"
+  )
+  try:
+    await call.transfer(destination)
+  except (RuntimeError, ImportError) as e:
+    # Not a phone call, or no server SDK. Reported back so the model can say so.
+    logger.warning("Transfer refused for session %s: %s", call.session_id, e)
+    return f"Could not transfer the call: {e}"
+  return f"Transferring the caller to {transfer_to}."
+
+
+async def send_dtmf(digits: str) -> str:
+  """Presses keys on the phone keypad, to drive an automated phone menu.
+
+  Args:
+    digits: The keys to press, in order, for example `"1"` or `"1234#"`.
+
+  Returns:
+    A confirmation of what was sent.
+  """
+  call = current_call()
+  await call.send_dtmf(digits)
+  return f"Sent the tones {digits}."
diff --git a/src/google/adk/integrations/livekit/_call_toolset.py b/src/google/adk/integrations/livekit/_call_toolset.py
new file mode 100644
index 00000000000..3c88acaebca
--- /dev/null
+++ b/src/google/adk/integrations/livekit/_call_toolset.py
@@ -0,0 +1,93 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""A toolset offering the call tools the current call can honor."""
+
+from __future__ import annotations
+
+from typing import Any
+from typing import Optional
+
+from ...agents.readonly_context import ReadonlyContext
+from ...features import experimental
+from ...features import FeatureName
+from ...tools.base_tool import BaseTool
+from ...tools.base_toolset import BaseToolset
+from ...tools.function_tool import FunctionTool
+from ._call_context import _current_call_or_none
+from ._call_tools import end_call
+from ._call_tools import send_dtmf
+from ._call_tools import transfer_call
+
+
+@experimental(FeatureName.LIVEKIT)
+class LiveKitToolset(BaseToolset):
+  """Exposes the call tools that make sense for the call in progress.
+
+  Add it to an agent once and let the transport decide what is offered::
+
+      root_agent = Agent(
+          model="gemini-live-2.5-flash-native-audio",
+          instruction="...",
+          tools=[check_line_status, LiveKitToolset()],
+      )
+
+  Resolution happens per invocation:
+
+  | Call in progress          | Tools offered                          |
+  | :------------------------ | :------------------------------------- |
+  | None, e.g. under adk web  | nothing; the agent runs unchanged      |
+  | WebRTC                    | end_call                               |
+  | SIP                       | end_call, transfer_call, send_dtmf     |
+
+  Transfers and DTMF go to a SIP peer, so they are meaningless on WebRTC.
+  """
+
+  def __init__(self, **kwargs: Any):
+    """Initializes the toolset.
+
+    Args:
+      **kwargs: Passed to `BaseToolset`. Use `tool_filter` to withhold a tool,
+        for example `tool_filter=["transfer_call", "send_dtmf"]` for an agent
+        that should never decide the conversation is over.
+    """
+    super().__init__(**kwargs)
+    # Tool objects are stateless, so build them once rather than per turn.
+    self._end_call = FunctionTool(end_call)
+    self._transfer_call = FunctionTool(transfer_call)
+    self._send_dtmf = FunctionTool(send_dtmf)
+
+  async def get_tools(
+      self, readonly_context: Optional[ReadonlyContext] = None
+  ) -> list[BaseTool]:
+    """Returns the tools the call in progress can honor.
+
+    Args:
+      readonly_context: The invocation context. Only `tool_filter` reads it;
+        the call itself comes from the ambient context `LiveKitRunner`
+        publishes, which is set for the whole live session rather than per
+        turn.
+
+    Returns:
+      The applicable tools, which is empty when no call is in progress.
+    """
+    call = _current_call_or_none()
+    if call is None:
+      return []
+    tools: list[BaseTool] = [self._end_call]
+    if call.sip_participant is not None:
+      tools += [self._transfer_call, self._send_dtmf]
+    return [
+        tool for tool in tools if self._is_tool_selected(tool, readonly_context)
+    ]
diff --git a/src/google/adk/integrations/livekit/_livekit_runner.py b/src/google/adk/integrations/livekit/_livekit_runner.py
new file mode 100644
index 00000000000..80afdec9234
--- /dev/null
+++ b/src/google/adk/integrations/livekit/_livekit_runner.py
@@ -0,0 +1,908 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""LiveKit connector for ADK live agents.
+
+`LiveKitRunner` bridges an already-connected LiveKit room to an ADK `Runner`
+over the `LiveRequestQueue` -> `run_live()` -> `Event` contract. See README.md
+for the channels it publishes on and what clients see.
+"""
+
+from __future__ import annotations
+
+import asyncio
+from collections.abc import AsyncIterator
+from collections.abc import Callable
+import contextlib
+import io
+import json
+import logging
+import time
+from typing import Any
+from typing import Optional
+
+from google.genai import types
+
+from ...agents.live_request_queue import LiveRequest
+from ...agents.live_request_queue import LiveRequestQueue
+from ...agents.run_config import RunConfig
+from ...errors.already_exists_error import AlreadyExistsError
+from ...events.event import Event
+from ...features import experimental
+from ...features import FeatureName
+from ...runners import Runner
+from ._call_context import _use_call
+from ._call_context import LiveKitCall
+from ._call_context import SIP_CALL_ID_ATTRIBUTE
+from ._call_context import SIP_PHONE_NUMBER_ATTRIBUTE
+from ._call_context import SIP_TRUNK_PHONE_NUMBER_ATTRIBUTE
+from ._rtc import EventTypes
+from ._rtc import rtc
+from ._transcripts import TranscriptSegment
+
+try:
+  from PIL import Image
+except ImportError as e:
+  raise ImportError(
+      "Pillow is not installed. It is required to encode inbound video. "
+      'Install it with `pip install "google-adk[livekit]"`.'
+  ) from e
+
+logger = logging.getLogger("google_adk." + __name__)
+
+# LiveKit resamples inbound audio on subscribe, so the bridge only has to ask.
+_INPUT_SAMPLE_RATE = 16000
+_OUTPUT_SAMPLE_RATE = 24000
+_NUM_CHANNELS = 1
+_BYTES_PER_SAMPLE = 2  # 16-bit PCM.
+# ADK identifies live input by rate in the mime type, not by a bare audio/pcm.
+_AUDIO_MIME_TYPE = f"audio/pcm;rate={_INPUT_SAMPLE_RATE}"
+_VIDEO_MIME_TYPE = "image/jpeg"
+_OUTPUT_AUDIO_TRACK_NAME = "adk-agent-audio"
+
+# Live models sample video rather than consume it at capture rate.
+_VIDEO_FRAMES_PER_SECOND = 1.0
+_VIDEO_MAX_WIDTH = 1024
+_VIDEO_MAX_HEIGHT = 1024
+_VIDEO_JPEG_QUALITY = 75
+
+# LiveKit's standard channels, which their client SDKs already render.
+LK_CHAT_TOPIC = "lk.chat"
+_LK_AGENT_STATE_ATTRIBUTE = "lk.agent.state"
+
+# Tool activity has no LiveKit convention, so it gets an ADK topic.
+DATA_TOPIC = "adk"
+
+# Agent state values LiveKit's voice-assistant components understand.
+_STATE_INITIALIZING = "initializing"
+_STATE_LISTENING = "listening"
+_STATE_THINKING = "thinking"
+_STATE_SPEAKING = "speaking"
+
+# Digits arrive one event at a time; buffer them into one turn.
+_DTMF_IDLE_FLUSH_SECONDS = 1.5
+_DTMF_TERMINATORS = frozenset({"#"})
+
+# Long enough to cover a phone ringing, short enough that a room nobody joins
+# does not pin a worker for the length of a call.
+_DEFAULT_PARTICIPANT_WAIT = 30.0
+
+_USER_ROLE = "user"
+_AGENT_ROLE = "agent"
+
+
+class _EndOfPlayback:
+  """Sentinel that lets the playback task drain and exit at end of call."""
+
+
+_END_OF_PLAYBACK = _EndOfPlayback()
+
+
+@experimental(FeatureName.LIVEKIT)
+class LiveKitRunner:
+  """Bridges a LiveKit room to an ADK `Runner.run_live()` session.
+
+  One instance drives one call. Construct it once the room is connected, then
+  `await start()`::
+
+      room = rtc.Room()
+      await room.connect(livekit_url, agent_token)
+      await LiveKitRunner(
+          runner=runner, room=room, user_id="alice", session_id=room_name
+      ).start()
+  """
+
+  def __init__(
+      self,
+      runner: Runner,
+      room: rtc.Room,
+      *,
+      user_id: str,
+      session_id: str,
+      run_config: Optional[RunConfig] = None,
+      create_session: bool = True,
+      wait_for_participant: Optional[float] = _DEFAULT_PARTICIPANT_WAIT,
+  ):
+    """Initializes the runner.
+
+    Args:
+      runner: An unmodified ADK `Runner`.
+      room: An already-connected LiveKit room.
+      user_id: The ADK user id for the session.
+      session_id: The ADK session id for the session.
+      run_config: Replaces the default voice config wholesale. Set
+        `session_resumption` and the transcription options yourself.
+      create_session: Whether to create the ADK session if it is missing.
+      wait_for_participant: Seconds to wait for a caller to join before
+        opening the model connection. Pass None to start immediately.
+    """
+    self._runner = runner
+    self._room = room
+    self._user_id = user_id
+    self._session_id = session_id
+    self._run_config = run_config or _default_run_config()
+    self._create_session = create_session
+    self._wait_for_participant = wait_for_participant
+    self._queue = LiveRequestQueue()
+
+    self._audio_source = rtc.AudioSource(_OUTPUT_SAMPLE_RATE, _NUM_CHANNELS)
+    self._audio_track = rtc.LocalAudioTrack.create_audio_track(
+        _OUTPUT_AUDIO_TRACK_NAME, self._audio_source
+    )
+    # Playback is paced by the audio source, so it gets its own task rather
+    # than throttling the event pump to realtime.
+    self._playback: asyncio.Queue[bytes | _EndOfPlayback] = asyncio.Queue()
+    self._playback_task: Optional[asyncio.Task[None]] = None
+    self._output_track_sid: Optional[str] = None
+    self._forward_tasks: set[asyncio.Task[None]] = set()
+    self._ended = asyncio.Event()
+    # Set at start, so a tool running off the loop can still end the call.
+    self._loop: Optional[asyncio.AbstractEventLoop] = None
+    self._agent_state: Optional[str] = None
+    self._last_caller_state: dict[str, Any] = {}
+    # One in-flight transcription segment per side of the call: ADK reports
+    # inbound speech as the user's, whoever in the room actually said it.
+    self._segments: dict[str, TranscriptSegment] = {}
+    self._dtmf_digits: list[str] = []
+    self._dtmf_identity: Optional[str] = None
+    self._dtmf_flush_handle: Optional[asyncio.TimerHandle] = None
+    self._call = LiveKitCall(
+        room=room,
+        user_id=user_id,
+        session_id=session_id,
+        hang_up_callback=self._end_session,
+    )
+
+  @property
+  def call(self) -> LiveKitCall:
+    """The call handle tools reach through `current_call()`."""
+    return self._call
+
+  async def start(self) -> None:
+    """Runs the live session until the call ends.
+
+    Returns when the caller hangs up, the room closes, or `run_live` finishes.
+    Releases the room and closes the queue either way, setup failures included.
+    """
+    self._loop = asyncio.get_running_loop()
+    # Named up front: teardown runs even if setup fails before these exist.
+    session: Optional[asyncio.Task[None]] = None
+    hung_up: Optional[asyncio.Task[bool]] = None
+    try:
+      await self._publish_output_audio_track()
+      await self._set_agent_state(_STATE_INITIALIZING)
+      # Before the session is created, so caller identity is in the session
+      # state from the first turn rather than arriving later as a delta.
+      await self._await_participant()
+      if self._create_session:
+        await self._ensure_session()
+      self._subscribe_existing_tracks()
+      self._subscribe_room_events()
+
+      # The session ends on whichever comes first: `run_live` finishing, or
+      # the call ending. Hanging up cannot be signalled by closing the queue
+      # -- `run_live` reads the resulting connection close as a dropped
+      # connection and reconnects on the resumption handle -- so it is
+      # tracked separately and cancels the event pump.
+      session = asyncio.create_task(self._forward_events())
+      hung_up = asyncio.create_task(self._ended.wait())
+      done, _ = await asyncio.wait(
+          {session, hung_up}, return_when=asyncio.FIRST_COMPLETED
+      )
+      if session in done:
+        session.result()  # Re-raise whatever ended the session.
+    finally:
+      if self._dtmf_flush_handle is not None:
+        self._dtmf_flush_handle.cancel()
+      started: list[asyncio.Task[Any]] = [
+          task for task in (session, hung_up) if task is not None
+      ]
+      pending = [*started, *self._forward_tasks]
+      for task in pending:
+        task.cancel()
+      # Awaited, and not merely cancelled, because a forwarder's `finally`
+      # still pushes onto the queue we are about to close.
+      await asyncio.gather(*pending, return_exceptions=True)
+      self._unsubscribe_room_events()
+      self._queue.close()
+
+  def _subscribe_room_events(self) -> None:
+    """Wires the room's callbacks to this session."""
+    for event, handler in self._room_handlers():
+      self._room.on(event, handler)
+    self._room.register_text_stream_handler(LK_CHAT_TOPIC, self._on_chat_stream)
+
+  def _unsubscribe_room_events(self) -> None:
+    """Releases the room, so a second session can reuse it.
+
+    LiveKit refuses a duplicate handler on a text-stream topic.
+    """
+    for event, handler in self._room_handlers():
+      with contextlib.suppress(Exception):
+        self._room.off(event, handler)
+    with contextlib.suppress(Exception):
+      self._room.unregister_text_stream_handler(LK_CHAT_TOPIC)
+
+  def _room_handlers(
+      self,
+  ) -> tuple[tuple[EventTypes, Callable[..., None]], ...]:
+    return (
+        ("track_subscribed", self._on_track_subscribed),
+        ("data_received", self._on_data_received),
+        ("sip_dtmf_received", self._on_dtmf_received),
+        ("disconnected", self._on_disconnected),
+        ("participant_disconnected", self._on_participant_disconnected),
+        ("participant_attributes_changed", self._on_participant_attributes),
+    )
+
+  async def _await_participant(self) -> None:
+    """Waits for a caller to join before the model connection is opened.
+
+    A live connection fixes its tool declarations when it opens, and
+    `LiveKitToolset` reads the room to decide what to offer. Starting first
+    would hide the telephony tools for the whole call.
+    """
+    if self._wait_for_participant is None:
+      return
+
+    joined: asyncio.Future[None] = asyncio.get_running_loop().create_future()
+
+    def _on_participant_connected(participant: rtc.RemoteParticipant) -> None:
+      del participant  # Any caller will do; identity is read off the room.
+      if not joined.done():
+        joined.set_result(None)
+
+    # Registered before the room is inspected, so a participant arriving in
+    # between is caught by the handler rather than missed by both.
+    self._room.on("participant_connected", _on_participant_connected)
+    try:
+      if self._room.remote_participants:
+        return
+      logger.debug(
+          "Waiting up to %ss for a caller.", self._wait_for_participant
+      )
+      await asyncio.wait_for(joined, self._wait_for_participant)
+    except asyncio.TimeoutError:
+      # An agent alone in a room is odd but not an error, so start anyway.
+      logger.warning(
+          "No caller joined room %s within %ss; starting the session anyway."
+          " A caller arriving later is served, but tools that depend on the"
+          " call kind are resolved by then.",
+          self._room.name,
+          self._wait_for_participant,
+      )
+    finally:
+      with contextlib.suppress(Exception):
+        self._room.off("participant_connected", _on_participant_connected)
+
+  async def _ensure_session(self) -> None:
+    """Creates the ADK session for this room if it does not exist yet.
+
+    `Runner` raises `SessionNotFoundError` rather than creating one, and a
+    freshly joined room has none.
+    """
+    session_service = self._runner.session_service
+    session = await session_service.get_session(
+        app_name=self._runner.app_name,
+        user_id=self._user_id,
+        session_id=self._session_id,
+    )
+    if session is not None:
+      return
+    try:
+      await session_service.create_session(
+          app_name=self._runner.app_name,
+          user_id=self._user_id,
+          session_id=self._session_id,
+          state=self._caller_state(),
+      )
+    except AlreadyExistsError:
+      # A retried dispatch can land two workers on one room; the loser just
+      # uses the session the winner made.
+      logger.debug("Session %s already created concurrently.", self._session_id)
+
+  # -- Caller identity -------------------------------------------------------
+
+  def _caller_state(self) -> dict[str, Any]:
+    """Session state describing the caller, copied from SIP attributes.
+
+    Empty unless the call arrived over SIP.
+    """
+    participant = self._call.sip_participant
+    if participant is None:
+      return {}
+    attributes = self._call.sip_attributes()
+    return {
+        "livekit_is_phone_call": True,
+        "livekit_caller_identity": participant.identity,
+        "livekit_caller_phone_number": attributes.get(
+            SIP_PHONE_NUMBER_ATTRIBUTE
+        ),
+        "livekit_called_phone_number": attributes.get(
+            SIP_TRUNK_PHONE_NUMBER_ATTRIBUTE
+        ),
+        "livekit_sip_call_id": attributes.get(SIP_CALL_ID_ATTRIBUTE),
+        "livekit_sip_attributes": attributes,
+    }
+
+  def _on_participant_attributes(
+      self,
+      changed: dict[str, str],
+      participant: rtc.Participant,
+  ) -> None:
+    """Refreshes caller state when LiveKit fills in SIP attributes late.
+
+    Attributes mapped from `X-*` SIP headers arrive asynchronously, so they
+    are routinely absent when the participant joins.
+    """
+    del changed  # Every attribute is re-read from the participant.
+    # The agent's own `lk.agent.state` writes come back through here too.
+    if participant is self._room.local_participant:
+      return
+    state = self._caller_state()
+    if not state or state == self._last_caller_state:
+      return
+    self._last_caller_state = state
+    self._queue.send(LiveRequest(state_delta=state))
+
+  # -- Lifecycle ------------------------------------------------------------
+
+  def _on_disconnected(self, *args: Any) -> None:
+    """Ends the session when this participant's room connection drops."""
+    del args  # LiveKit passes a disconnect reason on some versions.
+    logger.info("Room disconnected; ending live session.")
+    self._end_session()
+
+  def _on_participant_disconnected(self, *args: Any) -> None:
+    """Ends the session once the last remote participant has left.
+
+    Dispatch tears a worker down on its own, but a room joined directly would
+    leave the agent alone in it holding a model connection open.
+    """
+    del args  # The departing participant; identity is not needed here.
+    if not self._room.remote_participants:
+      logger.info("Last participant left; ending live session.")
+      self._end_session()
+
+  def _end_session(self) -> None:
+    """Signals `start` to stop driving the session.
+
+    Safe from any thread: ADK runs sync tools on a pool, and `asyncio.Event`
+    is not thread-safe.
+    """
+    loop = self._loop
+    if loop is None or _running_loop() is loop:
+      self._ended.set()
+    else:
+      loop.call_soon_threadsafe(self._ended.set)
+
+  # -- Bridge 1: inbound (room media track -> LiveRequestQueue) --------------
+
+  def _on_track_subscribed(
+      self,
+      track: rtc.Track,
+      publication: rtc.TrackPublication,
+      participant: rtc.RemoteParticipant,
+  ) -> None:
+    """Spawns a forwarder when a remote participant publishes a track."""
+    self._spawn_forwarder(
+        track, track_sid=publication.sid, identity=participant.identity
+    )
+
+  def _subscribe_existing_tracks(self) -> None:
+    """Forwards tracks already present when the worker joined the room."""
+    for participant in self._room.remote_participants.values():
+      for publication in participant.track_publications.values():
+        if publication.track is not None:
+          self._spawn_forwarder(
+              publication.track,
+              track_sid=publication.sid,
+              identity=participant.identity,
+          )
+
+  def _spawn_forwarder(
+      self,
+      track: rtc.Track,
+      *,
+      track_sid: Optional[str] = None,
+      identity: Optional[str] = None,
+  ) -> None:
+    if track.kind == rtc.TrackKind.KIND_AUDIO:
+      task = asyncio.create_task(
+          self._forward_audio(track, track_sid=track_sid, identity=identity)
+      )
+    elif track.kind == rtc.TrackKind.KIND_VIDEO:
+      task = asyncio.create_task(self._forward_video(track))
+    else:
+      logger.debug("Ignoring track of unsupported kind: %s", track.kind)
+      return
+    self._forward_tasks.add(task)
+    task.add_done_callback(self._forward_tasks.discard)
+
+  async def _forward_audio(
+      self,
+      track: rtc.Track,
+      *,
+      track_sid: Optional[str] = None,
+      identity: Optional[str] = None,
+  ) -> None:
+    """Streams a room audio track into the queue as 16kHz PCM blobs.
+
+    Args:
+      track: The remote audio track to forward.
+      track_sid: Attached to this speaker's transcripts, so clients can tell
+        the captions apart.
+      identity: Published as the sender of this speaker's transcripts.
+    """
+    self._segments.setdefault(
+        _USER_ROLE, TranscriptSegment(track_sid=track_sid, identity=identity)
+    ).bind(track_sid=track_sid, identity=identity)
+    audio_stream = rtc.AudioStream(
+        track, sample_rate=_INPUT_SAMPLE_RATE, num_channels=_NUM_CHANNELS
+    )
+    try:
+      async for event in audio_stream:
+        self._queue.send_realtime(
+            types.Blob(
+                mime_type=_AUDIO_MIME_TYPE,
+                data=bytes(event.frame.data),
+            )
+        )
+    finally:
+      # The track ended, so flush the model's buffer or a server-VAD turn
+      # hangs waiting for input that will never arrive.
+      self._queue.send_audio_stream_end()
+
+  async def _forward_video(self, track: rtc.Track) -> None:
+    """Streams a room video track into the queue as JPEG image blobs."""
+    video_stream = rtc.VideoStream(track)
+    min_interval = 1.0 / _VIDEO_FRAMES_PER_SECOND
+    next_frame_at = 0.0
+    async for event in video_stream:
+      now = time.monotonic()
+      if now < next_frame_at:
+        continue
+      next_frame_at = now + min_interval
+      # CPU-bound; off the loop so a large frame cannot stall audio.
+      jpeg = await asyncio.to_thread(_encode_jpeg, event.frame)
+      self._queue.send_realtime(
+          types.Blob(mime_type=_VIDEO_MIME_TYPE, data=jpeg)
+      )
+
+  def _on_chat_stream(
+      self, reader: rtc.TextStreamReader, participant_identity: str
+  ) -> None:
+    """Reads a text message off LiveKit's chat topic as a user turn."""
+    # Keypad entries go out on this topic too; reading one back would feed
+    # the model its own input.
+    if participant_identity == self._room.local_participant.identity:
+      return
+    task = asyncio.create_task(self._read_chat_stream(reader))
+    # Referenced, or the garbage collector can cancel the read mid-message.
+    self._forward_tasks.add(task)
+    task.add_done_callback(self._forward_tasks.discard)
+
+  async def _read_chat_stream(self, reader: rtc.TextStreamReader) -> None:
+    text = await reader.read_all()
+    self._send_user_text(text)
+
+  def _on_data_received(self, packet: rtc.DataPacket) -> None:
+    """Forwards a text message on the ADK topic into the session.
+
+    Superseded by `lk.chat`; kept for clients written against this topic.
+    """
+    if packet.topic != DATA_TOPIC:
+      return
+    text = _inbound_text(packet.data)
+    if text:
+      self._send_user_text(text)
+
+  def _send_user_text(self, text: str) -> None:
+    if not text or not text.strip():
+      return
+    self._queue.send_content(
+        types.Content(role="user", parts=[types.Part(text=text)])
+    )
+
+  # -- Bridge 1b: inbound DTMF ----------------------------------------------
+
+  def _on_dtmf_received(self, dtmf: rtc.SipDTMF) -> None:
+    """Buffers a keypress, because forwarding each one starts a model turn."""
+    digit = dtmf.digit or ""
+    if not digit:
+      return
+    # Captured here because the flush can run from a timer, with no event.
+    self._dtmf_identity = getattr(dtmf.participant, "identity", None)
+    self._dtmf_digits.append(digit)
+    if self._dtmf_flush_handle is not None:
+      self._dtmf_flush_handle.cancel()
+      self._dtmf_flush_handle = None
+    if digit in _DTMF_TERMINATORS:
+      self._flush_dtmf()
+      return
+    self._dtmf_flush_handle = asyncio.get_running_loop().call_later(
+        _DTMF_IDLE_FLUSH_SECONDS, self._flush_dtmf
+    )
+
+  def _flush_dtmf(self) -> None:
+    """Hands the buffered keypresses to the model as a user turn."""
+    self._dtmf_flush_handle = None
+    if not self._dtmf_digits:
+      return
+    digits = "".join(self._dtmf_digits)
+    identity = self._dtmf_identity
+    self._dtmf_digits.clear()
+    self._dtmf_identity = None
+    # Two strings on purpose: rewording the model's prompt should not rewrite
+    # the conversation record.
+    self._send_user_text(
+        f"The caller pressed these keys on their phone keypad: {digits}"
+    )
+    task = asyncio.create_task(self._publish_keypad_entry(digits, identity))
+    # Referenced, or the garbage collector can cancel the publish mid-write.
+    self._forward_tasks.add(task)
+    task.add_done_callback(self._forward_tasks.discard)
+
+  async def _publish_keypad_entry(
+      self, digits: str, identity: Optional[str]
+  ) -> None:
+    """Publishes a keypad entry on the chat topic, as the caller.
+
+    LiveKit relays the tones but not the turn assembled from them, so without
+    this a transcript shows the agent answering nothing. Chat rather than
+    `lk.transcription`, which is for transcribed audio and names the track it
+    came from; a keypress is neither.
+    """
+    try:
+      writer = await self._room.local_participant.stream_text(
+          topic=LK_CHAT_TOPIC, sender_identity=identity
+      )
+      await writer.write(digits)
+      await writer.aclose()
+    except Exception:  # pylint: disable=broad-except
+      # Cosmetic; the model already has the digits.
+      logger.exception("Failed to publish a keypad entry.")
+
+  # -- Bridge 2: outbound (Event stream -> room) ----------------------------
+
+  async def _forward_events(self) -> None:
+    """Drives `run_live` and pushes agent output back into the room."""
+    # Set on this task, not in `start`: only descendants of the task that
+    # drives the agent inherit the context a tool is dispatched with.
+    with _use_call(self._call):
+      self._start_playback()
+      try:
+        events = self._runner.run_live(
+            user_id=self._user_id,
+            session_id=self._session_id,
+            live_request_queue=self._queue,
+            run_config=self._run_config,
+        )
+        await self._set_agent_state(_STATE_LISTENING)
+        # `aclosing` so cancelling this task also unwinds `run_live` and
+        # closes the model connection.
+        async with contextlib.aclosing(events) as event_stream:
+          await self._pump_events(event_stream)
+        # Ended on its own, so let queued speech finish rather than cutting
+        # the agent off mid-word.
+        await self._drain_playback()
+      finally:
+        # Suppressed: an exception from the pump is the interesting one.
+        with contextlib.suppress(Exception):
+          await self._stop_playback()
+        with contextlib.suppress(Exception):
+          await self._close_segments()
+
+  async def _pump_events(self, event_stream: AsyncIterator[Event]) -> None:
+    """Pushes each agent event out to the room."""
+    async for event in event_stream:
+      # Barge-in: drop queued speech, or the agent talks over the user for
+      # as long as the buffer lasts.
+      if event.interrupted:
+        await self._drop_pending_playback()
+        await self._close_segments()
+        await self._set_agent_state(_STATE_LISTENING)
+
+      audio = _audio_out(event)
+      for chunk in audio:
+        self._playback.put_nowait(chunk)
+
+      await self._publish_transcripts(event)
+
+      payloads = _data_out(event)
+      for payload in payloads:
+        await self._room.local_participant.publish_data(
+            payload, topic=DATA_TOPIC
+        )
+
+      await self._set_agent_state(_next_agent_state(event, bool(audio)))
+
+  def _start_playback(self) -> None:
+    """Starts the task that feeds queued speech to the room."""
+    self._playback_task = asyncio.create_task(self._playback_loop())
+    self._playback_task.add_done_callback(self._on_playback_done)
+
+  def _on_playback_done(self, task: asyncio.Task[None]) -> None:
+    """Ends the call if playback died, instead of going quietly mute.
+
+    Nothing else awaits this task while the call is running.
+    """
+    if task.cancelled():
+      return
+    error = task.exception()
+    if error is not None:
+      logger.error("Audio playback failed; ending the call.", exc_info=error)
+      self._end_session()
+
+  async def _playback_loop(self) -> None:
+    """Feeds queued speech to the room at playback pace.
+
+    `capture_frame` blocks once the buffer is full, which is why this is not
+    inline in the event pump.
+    """
+    while True:
+      chunk = await self._playback.get()
+      if isinstance(chunk, _EndOfPlayback):
+        return
+      await self._audio_source.capture_frame(
+          rtc.AudioFrame(
+              data=chunk,
+              sample_rate=_OUTPUT_SAMPLE_RATE,
+              num_channels=_NUM_CHANNELS,
+              samples_per_channel=len(chunk) // _BYTES_PER_SAMPLE,
+          )
+      )
+
+  async def _drain_playback(self) -> None:
+    """Waits for every queued frame to be handed over and played out."""
+    if self._playback_task is None:
+      return
+    await self._playback.put(_END_OF_PLAYBACK)
+    await self._playback_task
+    self._playback_task = None
+    # The source still holds up to `queue_size_ms` after the last frame is
+    # handed over, and returning now clips the agent's last words.
+    await self._audio_source.wait_for_playout()
+
+  async def _stop_playback(self) -> None:
+    """Stops playback immediately, discarding anything still queued."""
+    task, self._playback_task = self._playback_task, None
+    if task is None:
+      return
+    task.remove_done_callback(self._on_playback_done)
+    task.cancel()
+    with contextlib.suppress(asyncio.CancelledError):
+      await task
+
+  async def _drop_pending_playback(self) -> None:
+    """Discards speech that has not been played yet.
+
+    Playback is torn down rather than drained: the task may be suspended
+    inside `capture_frame` holding a chunk, and clearing the source's buffer
+    is what would release it.
+    """
+    await self._stop_playback()
+    while True:
+      try:
+        self._playback.get_nowait()
+      except asyncio.QueueEmpty:
+        break
+    self._audio_source.clear_queue()
+    self._start_playback()
+
+  async def _publish_output_audio_track(self) -> None:
+    publication = await self._room.local_participant.publish_track(
+        self._audio_track, rtc.TrackPublishOptions()
+    )
+    self._output_track_sid = publication.sid
+
+  # -- Outbound transcripts --------------------------------------------------
+
+  async def _publish_transcripts(self, event: Event) -> None:
+    """Mirrors an event's transcription onto LiveKit's transcription topic.
+
+    Partials carry an incremental fragment and finals the whole segment,
+    which maps onto LiveKit's interim/final stream pair.
+    """
+    for transcription, role in (
+        (event.input_transcription, _USER_ROLE),
+        (event.output_transcription, _AGENT_ROLE),
+    ):
+      if transcription is None or not transcription.text:
+        continue
+      await self._publish_transcript(
+          role=role, text=transcription.text, final=not event.partial
+      )
+
+  async def _publish_transcript(
+      self, *, role: str, text: str, final: bool
+  ) -> None:
+    segment = self._segments.setdefault(role, TranscriptSegment())
+    if role == _AGENT_ROLE:
+      segment.bind(track_sid=self._output_track_sid, identity=None)
+    # Captions are cosmetic and must never take down the call.
+    try:
+      local = self._room.local_participant
+      if final:
+        await segment.finish(local, text)
+      else:
+        await segment.write(local, text)
+    except Exception:  # pylint: disable=broad-except
+      logger.exception("Failed to publish a %s transcript.", role)
+
+  async def _close_segments(self) -> None:
+    """Closes any interim transcription stream left open."""
+    for role, segment in self._segments.items():
+      try:
+        await segment.close()
+      except Exception:  # pylint: disable=broad-except
+        # Logged, not suppressed: an unterminated stream leaves a caption
+        # hanging open on every client, and that is the only symptom.
+        logger.exception("Failed to close the %s transcript stream.", role)
+      segment.reset()
+
+  # -- Outbound agent state --------------------------------------------------
+
+  async def _set_agent_state(self, state: Optional[str]) -> None:
+    """Publishes the agent's state for LiveKit's voice-assistant UI."""
+    if state is None or state == self._agent_state:
+      return
+    try:
+      await self._room.local_participant.set_attributes(
+          {_LK_AGENT_STATE_ATTRIBUTE: state}
+      )
+    except Exception:  # pylint: disable=broad-except
+      # Cached only on success, or the next identical transition would
+      # short-circuit and strand the client's indicator.
+      logger.exception("Failed to publish agent state %s.", state)
+      return
+    self._agent_state = state
+
+
+def _running_loop() -> Optional[asyncio.AbstractEventLoop]:
+  """Returns the loop running on this thread, or None if there is none."""
+  try:
+    return asyncio.get_running_loop()
+  except RuntimeError:
+    return None
+
+
+def _next_agent_state(event: Event, has_audio: bool) -> Optional[str]:
+  """Maps an event to the state LiveKit clients should show, if it changed."""
+  if event.turn_complete:
+    return _STATE_LISTENING
+  if has_audio or (
+      event.output_transcription and event.output_transcription.text
+  ):
+    return _STATE_SPEAKING
+  if event.get_function_calls():
+    return _STATE_THINKING
+  return None
+
+
+def _default_run_config() -> RunConfig:
+  """Builds the default voice config.
+
+  Resumption is on because the history ADK replays on reconnect is assembled
+  before the call, so without a handle a reconnect loses the conversation.
+  Transcription is on because ADK enables it by itself only for an agent with
+  `sub_agents`, and without it `lk.transcription` is silent.
+  """
+  return RunConfig(
+      response_modalities=[types.Modality.AUDIO],
+      input_audio_transcription=types.AudioTranscriptionConfig(),
+      output_audio_transcription=types.AudioTranscriptionConfig(),
+      session_resumption=types.SessionResumptionConfig(),
+  )
+
+
+def _encode_jpeg(frame: rtc.VideoFrame) -> bytes:
+  """Encodes a room video frame as a downscaled JPEG.
+
+  `VideoFrame.convert` only changes pixel layout, so the frame needs a real
+  encoder before it can be sent as `image/jpeg`.
+  """
+  if frame.type != rtc.VideoBufferType.RGBA:
+    frame = frame.convert(rtc.VideoBufferType.RGBA)
+  image = Image.frombytes(
+      "RGBA", (frame.width, frame.height), bytes(frame.data)
+  ).convert("RGB")
+  image.thumbnail((_VIDEO_MAX_WIDTH, _VIDEO_MAX_HEIGHT))
+  buffer = io.BytesIO()
+  image.save(buffer, "JPEG", quality=_VIDEO_JPEG_QUALITY)
+  return buffer.getvalue()
+
+
+def _inbound_text(data: bytes) -> Optional[str]:
+  """Extracts the text of an inbound `{"type": "text", "text": ...}` message."""
+  with contextlib.suppress(UnicodeDecodeError, json.JSONDecodeError):
+    message = json.loads(data.decode("utf-8"))
+    if isinstance(message, dict) and message.get("type") == "text":
+      text = message.get("text")
+      if isinstance(text, str) and text:
+        return text
+  logger.debug("Ignoring unrecognized inbound data message.")
+  return None
+
+
+def _audio_out(event: Event) -> list[bytes]:
+  """Extracts raw output audio (24kHz PCM) from an event, if any."""
+  if not (event.content and event.content.parts):
+    return []
+  blobs: list[bytes] = []
+  for part in event.content.parts:
+    inline_data = part.inline_data
+    if (
+        inline_data is not None
+        and inline_data.data
+        and (inline_data.mime_type or "").startswith("audio/")
+    ):
+      blobs.append(inline_data.data)
+  return blobs
+
+
+def _data_out(event: Event) -> list[bytes]:
+  """Extracts tool activity from an event, for the ADK data topic.
+
+  Each payload carries the ADK function call id, which is what pairs a result
+  with its call. See README.md.
+  """
+  payloads: list[bytes] = []
+  if not (event.content and event.content.parts):
+    return payloads
+
+  for part in event.content.parts:
+    if part.function_call is not None:
+      payloads.append(
+          _encode({
+              "type": "function_call",
+              "id": part.function_call.id,
+              "name": part.function_call.name,
+              "args": part.function_call.args,
+          })
+      )
+    elif part.function_response is not None:
+      payloads.append(
+          _encode({
+              "type": "function_response",
+              "id": part.function_response.id,
+              "name": part.function_response.name,
+              "response": part.function_response.response,
+          })
+      )
+  return payloads
+
+
+def _encode(payload: dict[str, Any]) -> bytes:
+  return json.dumps(payload).encode("utf-8")
diff --git a/src/google/adk/integrations/livekit/_rtc.py b/src/google/adk/integrations/livekit/_rtc.py
new file mode 100644
index 00000000000..cc89878e15f
--- /dev/null
+++ b/src/google/adk/integrations/livekit/_rtc.py
@@ -0,0 +1,31 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""The one place the LiveKit media SDK is imported."""
+
+from __future__ import annotations
+
+try:
+  # `as rtc` so importers of this module type-check against the re-export.
+  from livekit import rtc as rtc
+except ImportError as e:
+  raise ImportError(
+      "livekit is not installed. Please install it with "
+      '`pip install "google-adk[livekit]"`.'
+  ) from e
+
+# `Room.on` takes a Literal of event names, not a str; rtc hides this one.
+EventTypes = rtc.room.EventTypes
+
+__all__ = ["EventTypes", "rtc"]
diff --git a/src/google/adk/integrations/livekit/_transcripts.py b/src/google/adk/integrations/livekit/_transcripts.py
new file mode 100644
index 00000000000..d103f7e7b87
--- /dev/null
+++ b/src/google/adk/integrations/livekit/_transcripts.py
@@ -0,0 +1,104 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Publishing ADK transcriptions on LiveKit's transcription topic."""
+
+from __future__ import annotations
+
+import logging
+from typing import Optional
+import uuid
+
+from ._rtc import rtc
+
+logger = logging.getLogger("google_adk." + __name__)
+
+LK_TRANSCRIPTION_TOPIC = "lk.transcription"
+
+_LK_TRANSCRIBED_TRACK_ID_ATTRIBUTE = "lk.transcribed_track_id"
+_LK_SEGMENT_ID_ATTRIBUTE = "lk.segment_id"
+_LK_TRANSCRIPTION_FINAL_ATTRIBUTE = "lk.transcription_final"
+
+
+class TranscriptSegment:
+  """One utterance being transcribed.
+
+  A stable id shared by the interim stream and the final stream that replaces
+  it, plus the track and identity of whoever spoke.
+  """
+
+  def __init__(
+      self,
+      *,
+      track_sid: Optional[str] = None,
+      identity: Optional[str] = None,
+  ):
+    self._track_sid = track_sid
+    self._identity = identity
+    self._segment_id = uuid.uuid4().hex
+    self._writer: Optional[rtc.TextStreamWriter] = None
+
+  def bind(self, *, track_sid: Optional[str], identity: Optional[str]) -> None:
+    """Attaches the speaker's track and identity, once they are known."""
+    if track_sid is not None:
+      self._track_sid = track_sid
+    if identity is not None:
+      self._identity = identity
+
+  def attributes(self, *, final: bool) -> dict[str, str]:
+    """Returns the LiveKit stream attributes describing this segment."""
+    attributes = {
+        _LK_SEGMENT_ID_ATTRIBUTE: self._segment_id,
+        _LK_TRANSCRIPTION_FINAL_ATTRIBUTE: "true" if final else "false",
+    }
+    if self._track_sid:
+      attributes[_LK_TRANSCRIBED_TRACK_ID_ATTRIBUTE] = self._track_sid
+    return attributes
+
+  async def write(self, local: rtc.LocalParticipant, text: str) -> None:
+    """Appends an incremental fragment to this segment's interim stream."""
+    if self._writer is None:
+      self._writer = await self._open(local, final=False)
+    await self._writer.write(text)
+
+  async def finish(self, local: rtc.LocalParticipant, text: str) -> None:
+    """Replaces the interim stream with the completed utterance."""
+    await self.close()
+    writer = await self._open(local, final=True)
+    await writer.write(text)
+    await writer.aclose()
+    self.reset()
+
+  async def _open(
+      self, local: rtc.LocalParticipant, *, final: bool
+  ) -> rtc.TextStreamWriter:
+    # Not `send_text`, which cannot set a sender identity, so the caller's own
+    # words would be attributed to the agent.
+    return await local.stream_text(
+        topic=LK_TRANSCRIPTION_TOPIC,
+        attributes=self.attributes(final=final),
+        sender_identity=self._identity,
+    )
+
+  async def close(self) -> None:
+    """Closes the interim stream, if one is open."""
+    if self._writer is None:
+      return
+    writer, self._writer = self._writer, None
+    await writer.aclose()
+
+  def reset(self) -> None:
+    """Starts a fresh segment for this speaker's next utterance."""
+    self._writer = None
+    self._segment_id = uuid.uuid4().hex
diff --git a/tests/unittests/integrations/livekit/conftest.py b/tests/unittests/integrations/livekit/conftest.py
new file mode 100644
index 00000000000..ad8c9190c6f
--- /dev/null
+++ b/tests/unittests/integrations/livekit/conftest.py
@@ -0,0 +1,163 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Room and runner doubles shared by the LiveKit connector tests."""
+
+from __future__ import annotations
+
+from unittest.mock import AsyncMock
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+import pytest
+
+pytest.importorskip("livekit")
+
+from google.adk.integrations.livekit import _livekit_runner
+from google.adk.integrations.livekit import _transcripts as _transcripts_module
+from google.adk.integrations.livekit import LiveKitRunner
+from livekit import rtc
+
+
+class FakeTextStreamWriter:
+  """Records what a caller streams, standing in for LiveKit's writer.
+
+  Deliberately mirrors `rtc.TextStreamWriter`'s real surface -- `aclose`, not
+  `close`. A fake shaped to the implementation instead of to the SDK will
+  happily green-light code that cannot work against a real room.
+  """
+
+  def __init__(self, topic, attributes, sender_identity):
+    self.topic = topic
+    self.attributes = attributes or {}
+    self.sender_identity = sender_identity
+    self.chunks: list[str] = []
+    self.closed = False
+
+  async def write(self, text: str) -> None:
+    self.chunks.append(text)
+
+  async def aclose(self) -> None:
+    self.closed = True
+
+  @property
+  def text(self) -> str:
+    return "".join(self.chunks)
+
+  @property
+  def is_final(self) -> bool:
+    return self.attributes.get("lk.transcription_final") == "true"
+
+
+def make_room(remote_participants: dict | None = None):
+  """A connected LiveKit room with async publish methods and no tracks.
+
+  `local_participant` is spec'd against the real `rtc.LocalParticipant`, so a
+  call with a keyword the SDK does not accept fails here rather than on a real
+  room.
+  """
+  room = MagicMock(spec=rtc.Room)
+  room.name = "test-room"
+  room.remote_participants = remote_participants or {}
+  room.stream_writers: list[FakeTextStreamWriter] = []
+
+  local = MagicMock(spec=rtc.LocalParticipant)
+  local.identity = "adk-agent"
+  local.publish_track = AsyncMock(
+      return_value=MagicMock(spec=rtc.LocalTrackPublication, sid="TR_agent")
+  )
+  local.publish_data = AsyncMock()
+  local.send_text = AsyncMock()
+  local.set_attributes = AsyncMock()
+  local.publish_dtmf = AsyncMock()
+
+  async def stream_text(*, topic="", attributes=None, sender_identity=None):
+    writer = FakeTextStreamWriter(topic, attributes, sender_identity)
+    room.stream_writers.append(writer)
+    return writer
+
+  local.stream_text = stream_text
+  room.local_participant = local
+  return room
+
+
+def sip_participant(attributes: dict[str, str], identity: str = "sip_caller"):
+  """A telephony caller carrying the given `sip.*` attributes."""
+  participant = MagicMock()
+  participant.kind = rtc.ParticipantKind.PARTICIPANT_KIND_SIP
+  participant.identity = identity
+  participant.attributes = attributes
+  participant.track_publications = {}
+  return participant
+
+
+def webrtc_participant(identity: str = "browser"):
+  """A browser caller, which is not a phone call."""
+  participant = MagicMock()
+  participant.kind = rtc.ParticipantKind.PARTICIPANT_KIND_STANDARD
+  participant.identity = identity
+  participant.attributes = {}
+  participant.track_publications = {}
+  return participant
+
+
+def make_lk_runner(runner, room, **kwargs) -> LiveKitRunner:
+  """Builds a runner with the outbound audio track stubbed out.
+
+  `rtc.AudioSource` reaches into the LiveKit FFI, which needs a live worker.
+  The replacement is spec'd against the real class so a call the SDK does not
+  support fails here rather than on a real room.
+
+  The wait for a caller is off unless a test asks for it, so a room built
+  without participants starts at once instead of stalling.
+  """
+  kwargs.setdefault("wait_for_participant", None)
+  with (
+      patch.object(_livekit_runner.rtc, "AudioSource"),
+      patch.object(_livekit_runner.rtc, "LocalAudioTrack"),
+  ):
+    lk_runner = LiveKitRunner(
+        runner=runner, room=room, user_id="u1", session_id="s1", **kwargs
+    )
+  lk_runner._audio_source = MagicMock(spec=rtc.AudioSource)
+  lk_runner._audio_source.capture_frame = AsyncMock()
+  lk_runner._audio_source.wait_for_playout = AsyncMock()
+  return lk_runner
+
+
+def _transcripts(room) -> list[FakeTextStreamWriter]:
+  """Text streams on the transcription topic, excluding chat and the rest."""
+  return [
+      writer
+      for writer in room.stream_writers
+      if writer.topic == _transcripts_module.LK_TRANSCRIPTION_TOPIC
+  ]
+
+
+def final_transcripts(room) -> list[FakeTextStreamWriter]:
+  """Every completed transcript published as a LiveKit text stream."""
+  return [writer for writer in _transcripts(room) if writer.is_final]
+
+
+def interim_transcripts(room) -> list[FakeTextStreamWriter]:
+  """Every in-progress transcript stream."""
+  return [writer for writer in _transcripts(room) if not writer.is_final]
+
+
+def agent_states(room) -> list[str]:
+  """Every agent state published, in order."""
+  return [
+      call.args[0][_livekit_runner._LK_AGENT_STATE_ATTRIBUTE]
+      for call in room.local_participant.set_attributes.await_args_list
+  ]
diff --git a/tests/unittests/integrations/livekit/test_call_tools.py b/tests/unittests/integrations/livekit/test_call_tools.py
new file mode 100644
index 00000000000..594659c30d2
--- /dev/null
+++ b/tests/unittests/integrations/livekit/test_call_tools.py
@@ -0,0 +1,489 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Tests for reaching the LiveKit call from inside an ADK tool.
+
+Covers the ambient call handle (`current_call()`) that `LiveKitRunner`
+publishes, and the prebuilt tools that use it to hang up, transfer, and drive
+a phone keypad.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import contextvars
+from unittest.mock import AsyncMock
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.runners import InMemoryRunner
+from google.adk.runners import Runner
+from google.genai import types
+import pytest
+
+pytest.importorskip("livekit")
+
+from google.adk.integrations.livekit import _call_context
+from google.adk.integrations.livekit import current_call
+from google.adk.integrations.livekit import end_call
+from google.adk.integrations.livekit import LiveKitCall
+from google.adk.integrations.livekit import LiveKitToolset
+from google.adk.integrations.livekit import send_dtmf
+from google.adk.integrations.livekit import transfer_call
+
+from tests.unittests.integrations.livekit.conftest import make_lk_runner
+from tests.unittests.integrations.livekit.conftest import make_room
+from tests.unittests.integrations.livekit.conftest import sip_participant
+from tests.unittests.integrations.livekit.conftest import webrtc_participant
+from tests.unittests.testing_utils import MockModel
+
+# --- Fixtures (minimal, one purpose each) ---
+
+
+def _make_call(room=None, hang_up_callback=None) -> LiveKitCall:
+  return LiveKitCall(
+      room=room or make_room(),
+      user_id="u1",
+      session_id="s1",
+      hang_up_callback=hang_up_callback or (lambda: None),
+  )
+
+
+def _idle_runner() -> Runner:
+  """A Runner whose `run_live` never finishes, like a real idle call."""
+  runner = MagicMock(spec=Runner)
+  runner.app_name = "test_app"
+  runner.session_service = MagicMock()
+  runner.session_service.get_session = AsyncMock(return_value=MagicMock())
+
+  async def run_live(**kwargs):
+    await asyncio.Event().wait()
+    yield  # pragma: no cover - unreachable, keeps this an async generator
+
+  runner.run_live = run_live
+  return runner
+
+
+@contextlib.asynccontextmanager
+async def _patched_livekit_api():
+  """Patches LiveKit's server API and yields the client the code will use."""
+  from livekit import api
+
+  client = MagicMock()
+  client.sip.transfer_sip_participant = AsyncMock()
+  client.room.delete_room = AsyncMock()
+
+  @contextlib.asynccontextmanager
+  async def _session(*args, **kwargs):
+    del args, kwargs
+    yield client
+
+  with patch.object(api, "LiveKitAPI", _session):
+    yield client
+
+
+def _transfer_request(client):
+  return client.sip.transfer_sip_participant.await_args.args[0]
+
+
+# --- Reaching the call from a tool ---
+
+
+def test_a_tool_outside_a_call_is_told_so():
+  """An agent run without LiveKit must fail loudly, not silently no-op."""
+  with pytest.raises(RuntimeError, match="No LiveKit call is in progress"):
+    current_call()
+
+
+async def test_real_tools_reach_the_call_during_a_live_session():
+  """Real FunctionTools on a real agent can act on the room.
+
+  Setup: an agent with one sync and one async tool, both of which read
+    `current_call()`, driven through a real `Runner.run_live`.
+  Act: the model answers the first user turn by calling both tools.
+  Assert: each tool saw the session's own call handle.
+
+  Sync and async tools are dispatched differently -- one onto a thread pool
+  through a copied context, one as a task -- so both are exercised.
+  """
+  seen: dict[str, str] = {}
+
+  def read_call_from_sync_tool() -> str:
+    """Reads the ambient call from a sync tool."""
+    seen["sync"] = current_call().session_id
+    return "ok"
+
+  async def read_call_from_async_tool() -> str:
+    """Reads the ambient call from an async tool."""
+    seen["async"] = current_call().session_id
+    return "ok"
+
+  model = MockModel.create(
+      responses=[
+          types.Part.from_function_call(
+              name="read_call_from_sync_tool", args={}
+          ),
+          types.Part.from_function_call(
+              name="read_call_from_async_tool", args={}
+          ),
+          "done",
+      ]
+  )
+  runner = InMemoryRunner(
+      agent=LlmAgent(
+          name="probe",
+          model=model,
+          tools=[read_call_from_sync_tool, read_call_from_async_tool],
+      ),
+      app_name="probe_app",
+  )
+  lk_runner = make_lk_runner(runner, make_room())
+
+  session = asyncio.create_task(lk_runner.start())
+  await asyncio.sleep(0)
+  lk_runner._queue.send_content(
+      types.Content(role="user", parts=[types.Part(text="go")])
+  )
+  try:
+    for _ in range(100):
+      if len(seen) == 2:
+        break
+      await asyncio.sleep(0.05)
+  finally:
+    session.cancel()
+    with contextlib.suppress(asyncio.CancelledError):
+      await session
+
+  assert seen == {"sync": "s1", "async": "s1"}
+
+
+async def test_the_call_does_not_leak_past_the_session():
+  """A second call in the same process must not see the first one's room."""
+  with _call_context._use_call(_make_call()):
+    pass
+
+  with pytest.raises(RuntimeError):
+    current_call()
+
+
+# --- Caller identity ---
+
+
+def test_the_caller_number_is_readable_on_a_phone_call():
+  """Tools look up customers by number, so it has to be reachable."""
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  call = _make_call(make_room({"sip_caller": participant}))
+
+  assert call.caller_phone_number == "+15105550100"
+
+
+def test_a_browser_caller_has_no_phone_number():
+  """WebRTC callers are not phone calls; nothing should be invented."""
+  call = _make_call(make_room({"browser": webrtc_participant()}))
+
+  assert call.caller_phone_number is None
+
+
+def test_sip_attributes_exclude_unrelated_participant_metadata():
+  """Only LiveKit's telephony attributes describe the call."""
+  participant = sip_participant(
+      {"sip.phoneNumber": "+15105550100", "app.theme": "dark"}
+  )
+  call = _make_call(make_room({"sip_caller": participant}))
+
+  assert call.sip_attributes() == {"sip.phoneNumber": "+15105550100"}
+
+
+# --- Hanging up ---
+
+
+async def test_hanging_up_a_phone_call_drops_the_phone_leg():
+  """A SIP caller is held up by the SIP service, not by a client.
+
+  Leaving the room is enough for a browser, which disconnects itself when the
+  agent goes. Do the same to a phone caller and they are left on an open line
+  listening to silence, so the room has to go.
+  """
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  ended = asyncio.Event()
+  call = _make_call(
+      make_room({"sip_caller": participant}), hang_up_callback=ended.set
+  )
+
+  async with _patched_livekit_api() as client:
+    with _call_context._use_call(call):
+      result = await end_call()
+
+  assert client.room.delete_room.await_args.args[0].room == "test-room"
+  assert ended.is_set()
+  assert "ending" in result
+
+
+async def test_hanging_up_a_browser_call_only_leaves_the_room():
+  """The browser tears its own side down, so no server call is needed.
+
+  Deleting the room here would work too, but it would make every hangup
+  depend on server API credentials that a WebRTC-only app has no other use
+  for.
+  """
+  ended = asyncio.Event()
+  call = _make_call(
+      make_room({"browser": webrtc_participant()}), hang_up_callback=ended.set
+  )
+
+  async with _patched_livekit_api() as client:
+    with _call_context._use_call(call):
+      await end_call()
+
+  client.room.delete_room.assert_not_awaited()
+  assert ended.is_set()
+
+
+async def test_a_failed_hangup_still_ends_the_session():
+  """A room that will not delete must not also strand the model connection.
+
+  The tool reports the failure instead of raising, so the model can say
+  something rather than the turn dying.
+  """
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  ended = asyncio.Event()
+  call = _make_call(
+      make_room({"sip_caller": participant}), hang_up_callback=ended.set
+  )
+
+  async with _patched_livekit_api() as client:
+    client.room.delete_room = AsyncMock(side_effect=RuntimeError("no auth"))
+    with _call_context._use_call(call):
+      result = await end_call()
+
+  assert ended.is_set()
+  assert "no auth" in result
+
+
+async def test_a_sync_tool_can_end_the_call_from_its_worker_thread():
+  """`end_call` reaches the runner even from off the event loop.
+
+  Setup: a live session, and a sync tool body invoked the way ADK invokes one
+    -- through a copied context on a worker thread.
+  Act: that body calls `end_call`.
+  Assert: the session actually stops.
+
+  ADK runs sync tools off the loop and `asyncio.Event` is not thread-safe, so
+  this is the harder of the two dispatch paths.
+  """
+  lk_runner = make_lk_runner(_idle_runner(), make_room())
+
+  session = asyncio.create_task(lk_runner.start())
+  await asyncio.sleep(0)
+
+  def _sync_tool_body():
+    asyncio.run(end_call())
+
+  with _call_context._use_call(lk_runner.call):
+    context = contextvars.copy_context()
+    await asyncio.get_running_loop().run_in_executor(
+        None, lambda: context.run(_sync_tool_body)
+    )
+
+  await asyncio.wait_for(session, timeout=5)
+
+
+# --- Keypad output ---
+
+
+async def test_pressing_keys_publishes_dtmf_tones():
+  """Driving a downstream IVR means sending real tones, not speaking digits."""
+  room = make_room()
+
+  with _call_context._use_call(_make_call(room)):
+    await send_dtmf("12#")
+
+  sent = [
+      (c.kwargs["code"], c.kwargs["digit"])
+      for c in room.local_participant.publish_dtmf.await_args_list
+  ]
+  assert sent == [(1, "1"), (2, "2"), (11, "#")]
+
+
+async def test_non_keypad_characters_are_skipped():
+  """A model that hallucinates a letter must not break the call."""
+  room = make_room()
+
+  with _call_context._use_call(_make_call(room)):
+    await send_dtmf("1z2")
+
+  assert room.local_participant.publish_dtmf.await_count == 2
+
+
+# --- Transfers ---
+
+
+async def test_transferring_hands_the_caller_to_another_number():
+  """Escalating to a human is the most-requested telephony behavior."""
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  call = _make_call(make_room({"sip_caller": participant}))
+
+  async with _patched_livekit_api() as client:
+    with _call_context._use_call(call):
+      await transfer_call("+15105550111")
+
+  request = _transfer_request(client)
+  assert request.transfer_to == "tel:+15105550111"
+  assert request.participant_identity == "sip_caller"
+
+
+async def test_transferring_a_browser_call_explains_itself():
+  """The model should hear why, so it can tell the user, not fail the turn."""
+  call = _make_call(make_room({"browser": webrtc_participant()}))
+
+  with _call_context._use_call(call):
+    result = await transfer_call("+15105550111")
+
+  assert "Could not transfer" in result
+
+
+async def test_a_sip_uri_destination_is_passed_through():
+  """Not every transfer target is a phone number."""
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  call = _make_call(make_room({"sip_caller": participant}))
+
+  async with _patched_livekit_api() as client:
+    with _call_context._use_call(call):
+      await transfer_call("sip:support@example.com")
+
+  assert _transfer_request(client).transfer_to == "sip:support@example.com"
+
+
+# --- App-specific data ---
+
+
+async def test_a_tool_can_push_data_to_clients():
+  """In-game actions and robot commands ride the room's data track."""
+  room = make_room()
+
+  await _make_call(room).send_data(b'{"action":"open_door"}', topic="game")
+
+  (payload,) = room.local_participant.publish_data.await_args.args
+  assert payload == b'{"action":"open_door"}'
+  assert (
+      room.local_participant.publish_data.await_args.kwargs["topic"] == "game"
+  )
+
+
+async def test_a_tool_can_call_the_client_and_read_its_reply():
+  """RPC is the round trip `send_data` cannot do.
+
+  A tool has to be able to reach the client and use its answer, which is what
+  forwarding an LLM function call to a game or app client depends on.
+  """
+  room = make_room({"player": webrtc_participant("player")})
+  room.local_participant.perform_rpc = AsyncMock(return_value="door opened")
+
+  reply = await _make_call(room).perform_rpc(
+      method="open_door", payload="north"
+  )
+
+  assert reply == "door opened"
+  kwargs = room.local_participant.perform_rpc.await_args.kwargs
+  assert kwargs["destination_identity"] == "player"
+  assert kwargs["method"] == "open_door"
+  assert kwargs["payload"] == "north"
+
+
+async def test_rpc_refuses_to_guess_between_two_participants():
+  """Picking a destination silently would send game actions to a bystander.
+
+  An explicit destination is honored in the same room, so the refusal is
+  about the guess rather than about multi-party rooms.
+  """
+  room = make_room({
+      "player": webrtc_participant("player"),
+      "spectator": webrtc_participant("spectator"),
+  })
+  room.local_participant.perform_rpc = AsyncMock(return_value="ok")
+  call = _make_call(room)
+
+  with pytest.raises(RuntimeError, match="2 remote participants"):
+    await call.perform_rpc(method="open_door", payload="north")
+
+  await call.perform_rpc(
+      method="open_door", payload="north", destination_identity="player"
+  )
+  assert (
+      room.local_participant.perform_rpc.await_args.kwargs[
+          "destination_identity"
+      ]
+      == "player"
+  )
+
+
+# --- The call toolset ---
+
+
+async def _toolset_names(toolset, call=None):
+  """Resolves the toolset with `call` published, as ADK would at runtime."""
+  if call is None:
+    return [tool.name for tool in await toolset.get_tools()]
+  with _call_context._use_call(call):
+    return [tool.name for tool in await toolset.get_tools()]
+
+
+async def test_the_toolset_offers_nothing_without_a_call():
+  """Under `adk web` the agent must not be offered a hangup it cannot do."""
+  assert await _toolset_names(LiveKitToolset()) == []
+
+
+async def test_the_toolset_offers_only_hangup_on_a_webrtc_call():
+  """A browser caller cannot be transferred or sent tones."""
+  call = _make_call(make_room({"browser": webrtc_participant()}))
+
+  assert await _toolset_names(LiveKitToolset(), call) == ["end_call"]
+
+
+async def test_the_toolset_adds_telephony_tools_on_a_phone_call():
+  """Only a SIP peer can be transferred or sent tones."""
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  call = _make_call(make_room({"sip_caller": participant}))
+
+  assert await _toolset_names(LiveKitToolset(), call) == [
+      "end_call",
+      "transfer_call",
+      "send_dtmf",
+  ]
+
+
+async def test_a_tool_filter_withholds_a_tool():
+  """Some agents must never decide the conversation is over."""
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  call = _make_call(make_room({"sip_caller": participant}))
+  toolset = LiveKitToolset(tool_filter=["transfer_call", "send_dtmf"])
+
+  assert await _toolset_names(toolset, call) == ["transfer_call", "send_dtmf"]
+
+
+async def test_the_toolset_goes_in_an_agents_tools_list():
+  """The point of the toolset: no model_copy, no rebuilt Runner."""
+  agent = LlmAgent(
+      name="support_agent",
+      model="gemini-live-2.5-flash",
+      tools=[LiveKitToolset()],
+  )
+  call = _make_call(make_room({"browser": webrtc_participant()}))
+
+  with _call_context._use_call(call):
+    tools = await agent.canonical_tools()
+
+  assert [tool.name for tool in tools] == ["end_call"]
diff --git a/tests/unittests/integrations/livekit/test_livekit_runner.py b/tests/unittests/integrations/livekit/test_livekit_runner.py
new file mode 100644
index 00000000000..4083c4e2105
--- /dev/null
+++ b/tests/unittests/integrations/livekit/test_livekit_runner.py
@@ -0,0 +1,1294 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Tests for the LiveKit connector's two frame bridges.
+
+Verifies that `LiveKitRunner` forwards inbound room media into the
+`LiveRequestQueue` in the formats ADK's live contract expects, and pushes
+outbound `run_live` events back to the room as audio, LiveKit-standard
+transcription streams, agent state, and tool activity.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import json
+from unittest.mock import AsyncMock
+from unittest.mock import MagicMock
+from unittest.mock import patch
+
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.agents.run_config import RunConfig
+from google.adk.errors.already_exists_error import AlreadyExistsError
+from google.adk.errors.session_not_found_error import SessionNotFoundError
+from google.adk.events.event import Event
+from google.adk.runners import InMemoryRunner
+from google.adk.runners import Runner
+from google.genai import types
+import pytest
+
+pytest.importorskip("livekit")
+
+from google.adk.integrations.livekit import _livekit_runner
+from google.adk.integrations.livekit import _transcripts
+from google.adk.integrations.livekit import LiveKitRunner
+from livekit import rtc
+import numpy as np
+
+from tests.unittests.integrations.livekit.conftest import agent_states
+from tests.unittests.integrations.livekit.conftest import final_transcripts
+from tests.unittests.integrations.livekit.conftest import interim_transcripts
+from tests.unittests.integrations.livekit.conftest import make_lk_runner
+from tests.unittests.integrations.livekit.conftest import make_room
+from tests.unittests.integrations.livekit.conftest import sip_participant
+from tests.unittests.isolated_import_utils import run_isolated
+from tests.unittests.testing_utils import MockModel
+
+# --- Fixtures (minimal, one purpose each) ---
+
+
+def _make_runner(events: list[Event]) -> Runner:
+  """A Runner whose run_live yields the given events then finishes.
+
+  The session already exists, so bridge tests exercise frame handling rather
+  than session setup. What `run_live` was called with is recorded on
+  `runner.live_kwargs`, so tests can assert on the config the connector
+  actually hands the runner instead of on its own attributes.
+  """
+  runner = MagicMock(spec=Runner)
+  runner.live_kwargs = {}
+
+  async def run_live(**kwargs):
+    runner.live_kwargs.update(kwargs)
+    for event in events:
+      yield event
+
+  runner.run_live = run_live
+  runner.app_name = "test_app"
+  runner.session_service = MagicMock()
+  runner.session_service.get_session = AsyncMock(return_value=MagicMock())
+  runner.session_service.create_session = AsyncMock()
+  return runner
+
+
+def _never_ending_runner() -> Runner:
+  """A Runner whose run_live never finishes, like a real idle call."""
+  runner = _make_runner([])
+
+  async def run_live(**kwargs):
+    await asyncio.Event().wait()
+    yield  # pragma: no cover - unreachable, keeps this an async generator
+
+  runner.run_live = run_live
+  return runner
+
+
+def _audio_event(data: bytes) -> Event:
+  return Event(
+      author="agent",
+      content=types.Content(
+          role="model",
+          parts=[
+              types.Part(
+                  inline_data=types.Blob(mime_type="audio/pcm", data=data)
+              )
+          ],
+      ),
+  )
+
+
+def _function_call_event(
+    name: str, args: dict, *, call_id: str | None = None
+) -> Event:
+  return Event(
+      author="agent",
+      content=types.Content(
+          role="model",
+          parts=[
+              types.Part(
+                  function_call=types.FunctionCall(
+                      id=call_id, name=name, args=args
+                  )
+              )
+          ],
+      ),
+  )
+
+
+def _function_response_event(
+    name: str, response: dict, *, call_id: str | None = None
+) -> Event:
+  return Event(
+      author="agent",
+      content=types.Content(
+          role="user",
+          parts=[
+              types.Part(
+                  function_response=types.FunctionResponse(
+                      id=call_id, name=name, response=response
+                  )
+              )
+          ],
+      ),
+  )
+
+
+def _transcript_event(text: str, *, role: str, partial: bool) -> Event:
+  transcription = types.Transcription(text=text, finished=not partial)
+  return Event(
+      author=role,
+      partial=partial,
+      input_transcription=transcription if role == "user" else None,
+      output_transcription=transcription if role == "agent" else None,
+  )
+
+
+def _published(room) -> list[dict]:
+  """Decodes every payload published on the ADK data topic."""
+  return [
+      json.loads(call.args[0])
+      for call in room.local_participant.publish_data.await_args_list
+  ]
+
+
+class _FakeStream:
+  """An async-iterable stand-in for rtc.AudioStream / rtc.VideoStream."""
+
+  def __init__(self, events):
+    self._events = events
+
+  def __call__(self, *args, **kwargs):
+    return self
+
+  def __aiter__(self):
+    async def gen():
+      for event in self._events:
+        yield event
+
+    return gen()
+
+
+def _rgba_frame(width: int = 16, height: int = 16) -> rtc.VideoFrame:
+  return rtc.VideoFrame(
+      width=width,
+      height=height,
+      type=rtc.VideoBufferType.RGBA,
+      data=bytearray(np.zeros((height, width, 4), dtype=np.uint8).tobytes()),
+  )
+
+
+# --- Outbound bridge: Event stream -> room ---
+
+
+async def test_the_default_run_config_survives_a_reconnect():
+  """Voice calls outlive a model connection, so resumption is on by default.
+
+  Without a resumption handle the reconnect that follows `go_away` replays
+  only pre-call history, silently restarting the conversation mid-call.
+  """
+  runner = _make_runner([])
+  lk_runner = make_lk_runner(runner, make_room())
+
+  await lk_runner._forward_events()
+
+  run_config = runner.live_kwargs["run_config"]
+  assert run_config.response_modalities == [types.Modality.AUDIO]
+  assert run_config.session_resumption is not None
+
+
+async def test_the_default_run_config_produces_captions():
+  """`lk.transcription` is silent unless transcription is asked for.
+
+  ADK enables it by itself only for an agent with `sub_agents`, so a plain
+  agent would publish audio and no captions to go with it.
+  """
+  runner = _make_runner([])
+  lk_runner = make_lk_runner(runner, make_room())
+
+  await lk_runner._forward_events()
+
+  run_config = runner.live_kwargs["run_config"]
+  assert run_config.input_audio_transcription is not None
+  assert run_config.output_audio_transcription is not None
+
+
+async def test_run_config_is_used_when_provided():
+  """A caller-supplied run_config is passed through untouched."""
+  run_config = RunConfig(
+      response_modalities=[types.Modality.AUDIO],
+      output_audio_transcription=types.AudioTranscriptionConfig(),
+  )
+  runner = _make_runner([])
+  lk_runner = make_lk_runner(runner, make_room(), run_config=run_config)
+
+  await lk_runner._forward_events()
+
+  assert runner.live_kwargs["run_config"] is run_config
+
+
+async def test_output_audio_is_played_out_before_the_session_returns():
+  """A session that ends on its own does not cut the agent off mid-word.
+
+  Handing frames over is not the same as the caller having heard them: the
+  audio source holds up to a second of speech after the last `capture_frame`
+  returns, so returning early would clip the agent's last words.
+  """
+  events = [_audio_event(b"\x01\x02"), _audio_event(b"\x03\x04")]
+  lk_runner = make_lk_runner(_make_runner(events), make_room())
+
+  await lk_runner._forward_events()
+
+  assert lk_runner._audio_source.capture_frame.await_count == 2
+  lk_runner._audio_source.wait_for_playout.assert_awaited_once()
+
+
+async def test_a_dead_playback_task_ends_the_call():
+  """A broken audio source ends the call rather than going quietly mute.
+
+  Nothing awaits the playback task while the call runs, so without this the
+  caller would hear silence for the rest of the call with no error anywhere.
+  """
+  lk_runner = make_lk_runner(_never_ending_runner(), make_room())
+  lk_runner._audio_source.capture_frame = AsyncMock(
+      side_effect=RuntimeError("audio device gone")
+  )
+
+  session = asyncio.create_task(lk_runner.start())
+  await asyncio.sleep(0)
+  lk_runner._playback.put_nowait(b"\x01\x02")
+
+  await asyncio.wait_for(session, timeout=5)
+
+
+async def test_event_pump_does_not_wait_on_audio_playback():
+  """Playback pacing must not hold up transcripts, tools, or barge-in.
+
+  `capture_frame` blocks once the audio buffer is full. If the pump drained
+  playback inline it would run at realtime speed, and every later event --
+  including the interruption that is supposed to stop playback -- would queue
+  up behind the speech it is meant to cancel.
+  """
+  room = make_room()
+  events = [_audio_event(b"\x01\x02"), _function_call_event("roll_die", {})]
+  lk_runner = make_lk_runner(_make_runner(events), room)
+  playing = asyncio.Event()
+  release = asyncio.Event()
+
+  async def _slow_playback(_frame):
+    playing.set()
+    await release.wait()
+
+  lk_runner._audio_source.capture_frame = AsyncMock(side_effect=_slow_playback)
+
+  session = asyncio.create_task(lk_runner._forward_events())
+  await asyncio.wait_for(playing.wait(), timeout=5)
+  await asyncio.sleep(0)
+
+  # The tool call reached the room while playback was still blocked.
+  assert [payload["type"] for payload in _published(room)] == ["function_call"]
+
+  release.set()
+  await asyncio.wait_for(session, timeout=5)
+
+
+async def test_interrupted_event_drops_unplayed_speech():
+  """Barge-in drops queued speech instead of talking over the user.
+
+  Setup: two audio chunks queued behind a playback task blocked on the first,
+    then an interruption.
+  Act: run the pump to completion.
+  Assert: the buffer is cleared and the second chunk is never captured.
+
+  Clearing the source is also what releases a task suspended inside
+  `capture_frame`, which is why playback is torn down rather than drained.
+  """
+  playing = asyncio.Event()
+  captured: list[bytes] = []
+
+  async def _blocking_playback(frame):
+    captured.append(bytes(frame.data))
+    playing.set()
+    await asyncio.Event().wait()  # Never completes; the interrupt cancels it.
+
+  runner = _make_runner([])
+
+  async def run_live(**kwargs):
+    yield _audio_event(b"\x01\x02")
+    yield _audio_event(b"\x03\x04")
+    # Interrupt only once the first chunk is genuinely on the wire, so the
+    # second is provably still queued rather than merely un-scheduled.
+    await playing.wait()
+    yield Event(author="agent", interrupted=True)
+
+  runner.run_live = run_live
+  lk_runner = make_lk_runner(runner, make_room())
+  lk_runner._audio_source.capture_frame = AsyncMock(
+      side_effect=_blocking_playback
+  )
+
+  await lk_runner._forward_events()
+
+  assert captured == [b"\x01\x02"]
+  lk_runner._audio_source.clear_queue.assert_called_once()
+
+
+async def test_tool_activity_is_published_on_the_adk_data_topic():
+  """A function_call event reaches clients as JSON on the ADK topic."""
+  room = make_room()
+  lk_runner = make_lk_runner(
+      _make_runner([_function_call_event("roll_die", {"sides": 6})]), room
+  )
+
+  await lk_runner._forward_events()
+
+  (payload,) = _published(room)
+  assert payload["type"] == "function_call"
+  assert payload["name"] == "roll_die"
+  assert payload["args"] == {"sides": 6}
+  _, kwargs = room.local_participant.publish_data.await_args
+  assert kwargs["topic"] == _livekit_runner.DATA_TOPIC
+
+
+async def test_tool_payloads_carry_the_call_id_that_pairs_them():
+  """A client has to know which call a result answers.
+
+  Matching on the tool name is wrong as soon as one tool is called twice in a
+  turn, which parallel calls make routine, so the ADK function call id goes
+  on the wire and both halves carry it.
+  """
+  room = make_room()
+  events = [
+      _function_call_event("roll_die", {"sides": 20}, call_id="call-1"),
+      _function_response_event("roll_die", {"result": 19}, call_id="call-1"),
+  ]
+  lk_runner = make_lk_runner(_make_runner(events), room)
+
+  await lk_runner._forward_events()
+
+  call, response = _published(room)
+  assert call["id"] == response["id"] == "call-1"
+  assert call["type"] == "function_call"
+  assert response["type"] == "function_response"
+  assert response["response"] == {"result": 19}
+
+
+# --- Outbound transcripts: LiveKit's standard channel ---
+
+
+async def test_final_transcript_published_and_closed_on_livekit_topic():
+  """Completed transcripts go where every LiveKit client already listens.
+
+  An unterminated stream leaves the caption hanging open on every client, so
+  the stream is closed as well as written.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(
+      _make_runner(
+          [_transcript_event("you rolled a four", role="agent", partial=False)]
+      ),
+      room,
+  )
+
+  await lk_runner._forward_events()
+
+  (final,) = final_transcripts(room)
+  assert final.text == "you rolled a four"
+  assert final.topic == _transcripts.LK_TRANSCRIPTION_TOPIC
+  assert final.closed
+
+
+async def test_the_final_transcript_replaces_the_interim_one():
+  """A client replaces the caption it drew; it does not extend it.
+
+  Setup: two partial fragments and then the completed utterance, which is
+    exactly what ADK emits and what LiveKit's interim/final pair models.
+  Act: run the pump to completion.
+  Assert: both streams carry the whole utterance under one segment id, and
+    the interim stream is closed by the final one.
+
+  Send a remainder rather than the whole utterance here and every caption in
+  every client renders the tail twice.
+  """
+  room = make_room()
+  events = [
+      _transcript_event("you ", role="agent", partial=True),
+      _transcript_event("rolled a four", role="agent", partial=True),
+      _transcript_event("you rolled a four", role="agent", partial=False),
+  ]
+  lk_runner = make_lk_runner(_make_runner(events), room)
+
+  await lk_runner._forward_events()
+
+  (interim,) = interim_transcripts(room)
+  (final,) = final_transcripts(room)
+  assert interim.text == final.text == "you rolled a four"
+  assert (
+      interim.attributes["lk.segment_id"] == final.attributes["lk.segment_id"]
+  )
+  assert interim.closed
+
+
+async def test_consecutive_utterances_get_their_own_segment_ids():
+  """Two sentences must not be rendered as one endlessly-growing caption."""
+  room = make_room()
+  events = [
+      _transcript_event("first", role="agent", partial=False),
+      _transcript_event("second", role="agent", partial=False),
+  ]
+  lk_runner = make_lk_runner(_make_runner(events), room)
+
+  await lk_runner._forward_events()
+
+  segment_ids = {
+      writer.attributes["lk.segment_id"] for writer in final_transcripts(room)
+  }
+  assert len(segment_ids) == 2
+
+
+async def test_agent_transcript_carries_the_agent_audio_track():
+  """The transcribed track id is how a client tells the speakers apart."""
+  room = make_room()
+  lk_runner = make_lk_runner(
+      _make_runner([_transcript_event("hello", role="agent", partial=False)]),
+      room,
+  )
+  await lk_runner._publish_output_audio_track()
+
+  await lk_runner._forward_events()
+
+  assert (
+      final_transcripts(room)[0].attributes["lk.transcribed_track_id"]
+      == "TR_agent"
+  )
+
+
+@pytest.mark.parametrize("partial", [True, False])
+async def test_caller_transcript_is_attributed_to_the_caller(partial):
+  """The user's words must appear to come from the user, not the agent.
+
+  Both sides are published by the agent's participant, so without an explicit
+  sender identity a client renders the caller's own speech as the agent's.
+  Interim and final are checked together because they take different code
+  paths.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  with patch.object(_livekit_runner.rtc, "AudioStream", _FakeStream([])):
+    await lk_runner._forward_audio(
+        MagicMock(), track_sid="TR_caller", identity="caller-1"
+    )
+
+  await lk_runner._publish_transcript(
+      role="user", text="roll a die", final=not partial
+  )
+
+  (writer,) = room.stream_writers
+  assert writer.sender_identity == "caller-1"
+  assert writer.attributes["lk.transcribed_track_id"] == "TR_caller"
+
+
+async def test_a_failing_transcript_does_not_end_the_call():
+  """Captions are cosmetic; a client that cannot render them is not fatal."""
+  room = make_room()
+  room.local_participant.stream_text = AsyncMock(
+      side_effect=RuntimeError("nope")
+  )
+  lk_runner = make_lk_runner(
+      _make_runner([_transcript_event("hello", role="agent", partial=False)]),
+      room,
+  )
+
+  await lk_runner._forward_events()  # Must not raise.
+
+
+# --- Outbound agent state ---
+
+
+async def test_agent_state_starts_listening():
+  """A connected agent tells clients it is ready before anyone speaks."""
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+
+  await lk_runner._forward_events()
+
+  assert agent_states(room)[0] == "listening"
+
+
+async def test_agent_state_reports_speaking_while_audio_flows():
+  """Voice UI needs to know when the agent has the floor."""
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([_audio_event(b"\x01\x02")]), room)
+
+  await lk_runner._forward_events()
+
+  assert "speaking" in agent_states(room)
+
+
+async def test_agent_state_reports_thinking_while_a_tool_runs():
+  """A tool call is dead air; clients show a thinking indicator instead."""
+  room = make_room()
+  lk_runner = make_lk_runner(
+      _make_runner([_function_call_event("roll_die", {"sides": 6})]), room
+  )
+
+  await lk_runner._forward_events()
+
+  assert "thinking" in agent_states(room)
+
+
+async def test_agent_state_returns_to_listening_after_a_turn():
+  """The floor goes back to the caller when the agent finishes."""
+  room = make_room()
+  events = [
+      _audio_event(b"\x01\x02"),
+      Event(author="agent", turn_complete=True),
+  ]
+  lk_runner = make_lk_runner(_make_runner(events), room)
+
+  await lk_runner._forward_events()
+
+  assert agent_states(room)[-1] == "listening"
+
+
+async def test_unchanged_agent_state_is_not_republished():
+  """Every attribute update is a room message; repeats are pure noise."""
+  room = make_room()
+  events = [
+      _audio_event(b"\x01\x02"),
+      _audio_event(b"\x03\x04"),
+      _audio_event(b"\x05\x06"),
+  ]
+  lk_runner = make_lk_runner(_make_runner(events), room)
+
+  await lk_runner._forward_events()
+
+  assert agent_states(room).count("speaking") == 1
+
+
+# --- Inbound bridge: room -> LiveRequestQueue ---
+
+
+async def test_inbound_audio_track_forwarded_as_pcm_blob():
+  """Frames from a room audio track land on the queue as 16kHz PCM blobs."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+
+  frame_event = MagicMock()
+  frame_event.frame.data = b"\x10\x20"
+  with patch.object(
+      _livekit_runner.rtc, "AudioStream", _FakeStream([frame_event])
+  ):
+    await lk_runner._forward_audio(MagicMock())
+
+  blob = (await lk_runner._queue.get()).blob
+  # The rate belongs in the mime type; a bare `audio/pcm` leaves the model
+  # guessing at the sample rate.
+  assert blob.mime_type == "audio/pcm;rate=16000"
+  assert blob.data == b"\x10\x20"
+
+
+async def test_audio_stream_end_signalled_when_track_ends():
+  """A muted or unpublished track flushes the model's audio buffer.
+
+  Without the flush a server-VAD turn hangs waiting for input that will never
+  arrive.
+  """
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  lk_runner._queue.send_audio_stream_end = MagicMock()
+
+  with patch.object(_livekit_runner.rtc, "AudioStream", _FakeStream([])):
+    await lk_runner._forward_audio(MagicMock())
+
+  lk_runner._queue.send_audio_stream_end.assert_called_once()
+
+
+async def test_inbound_video_track_forwarded_as_real_jpeg():
+  """Video frames are JPEG-encoded, not raw buffers labelled image/jpeg."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  captured: list[types.Blob] = []
+  lk_runner._queue.send_realtime = captured.append
+
+  frame_event = MagicMock()
+  frame_event.frame = _rgba_frame(64, 48)
+  with patch.object(
+      _livekit_runner.rtc, "VideoStream", _FakeStream([frame_event])
+  ):
+    await lk_runner._forward_video(MagicMock())
+
+  assert len(captured) == 1
+  assert captured[0].mime_type == "image/jpeg"
+  assert captured[0].data.startswith(b"\xff\xd8")  # JPEG SOI marker.
+
+
+async def test_video_frames_are_rate_limited():
+  """Live models sample video; forwarding at capture rate floods the queue."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  captured: list[types.Blob] = []
+  lk_runner._queue.send_realtime = captured.append
+
+  frame = _rgba_frame()
+  frame_events = []
+  for _ in range(30):  # One second of 30fps capture.
+    event = MagicMock()
+    event.frame = frame
+    frame_events.append(event)
+
+  with patch.object(
+      _livekit_runner.rtc, "VideoStream", _FakeStream(frame_events)
+  ):
+    await lk_runner._forward_video(MagicMock())
+
+  assert len(captured) == 1
+
+
+# --- Inbound text ---
+
+
+def _data_packet(message: dict, topic: str | None = None):
+  packet = MagicMock()
+  packet.topic = topic or _livekit_runner.DATA_TOPIC
+  packet.data = json.dumps(message).encode("utf-8")
+  return packet
+
+
+async def test_a_chat_message_becomes_a_user_turn():
+  """Text typed in any LiveKit client reaches the agent with no ADK code.
+
+  LiveKit hands the reader over synchronously, so the read is spawned as a
+  task; that task has to be referenced or the garbage collector can cancel
+  the message out from under the caller.
+  """
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  reader = MagicMock()
+  reader.read_all = AsyncMock(return_value="roll a die")
+
+  lk_runner._on_chat_stream(reader, "caller-1")
+  await asyncio.gather(*lk_runner._forward_tasks)
+
+  request = await lk_runner._queue.get()
+  assert request.content.role == "user"
+  assert request.content.parts[0].text == "roll a die"
+
+
+async def test_an_empty_chat_message_is_ignored():
+  """An accidental empty send must not start a model turn."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  lk_runner._queue.send_content = MagicMock()
+  reader = MagicMock()
+  reader.read_all = AsyncMock(return_value="   ")
+
+  await lk_runner._read_chat_stream(reader)
+
+  lk_runner._queue.send_content.assert_not_called()
+
+
+async def test_chat_topic_is_livekits_own():
+  """Registering elsewhere would mean every client had to be taught ADK."""
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+
+  await lk_runner.start()
+
+  topics = {
+      call.args[0] for call in room.register_text_stream_handler.mock_calls
+  }
+  assert _livekit_runner.LK_CHAT_TOPIC in topics
+
+
+async def test_inbound_text_on_the_adk_topic_still_works():
+  """A client written against the ADK topic keeps working."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  captured: list[types.Content] = []
+  lk_runner._queue.send_content = captured.append
+
+  lk_runner._on_data_received(
+      _data_packet({"type": "text", "text": "roll a die"})
+  )
+
+  assert captured[0].parts[0].text == "roll a die"
+
+
+async def test_data_on_another_topic_is_ignored():
+  """The bridge only claims its own topic; the room is shared."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  lk_runner._queue.send_content = MagicMock()
+
+  lk_runner._on_data_received(
+      _data_packet({"type": "text", "text": "not for us"}, topic="other-app")
+  )
+
+  lk_runner._queue.send_content.assert_not_called()
+
+
+async def test_malformed_data_message_is_ignored():
+  """A non-JSON payload must not take down the session."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  lk_runner._queue.send_content = MagicMock()
+
+  packet = MagicMock()
+  packet.topic = _livekit_runner.DATA_TOPIC
+  packet.data = b"\xff\xfe not json"
+
+  lk_runner._on_data_received(packet)
+
+  lk_runner._queue.send_content.assert_not_called()
+
+
+# --- Inbound DTMF ---
+
+
+def _dtmf(digit: str, identity: str | None = "sip_caller"):
+  packet = MagicMock()
+  packet.digit = digit
+  packet.participant = MagicMock(identity=identity)
+  return packet
+
+
+def _chat_messages(room) -> list:
+  """Every message published on LiveKit's chat topic."""
+  return [
+      writer
+      for writer in room.stream_writers
+      if writer.topic == _livekit_runner.LK_CHAT_TOPIC
+  ]
+
+
+async def test_keypad_entry_reaches_the_agent_as_one_turn():
+  """A caller keying an account number is one input, not six.
+
+  Forwarding each keypress on its own would start a model turn per digit, so
+  digits are held until the caller signals the end with `#`.
+  """
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  captured: list[types.Content] = []
+  lk_runner._queue.send_content = captured.append
+
+  for digit in "4321#":
+    lk_runner._on_dtmf_received(_dtmf(digit))
+
+  assert len(captured) == 1
+  assert "4321#" in captured[0].parts[0].text
+
+
+async def test_keypad_entry_is_flushed_when_the_caller_stops_typing():
+  """Not every IVR entry ends in `#`, so an idle pause ends it too."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  captured: list[types.Content] = []
+  lk_runner._queue.send_content = captured.append
+
+  with patch.object(_livekit_runner, "_DTMF_IDLE_FLUSH_SECONDS", 0.01):
+    lk_runner._on_dtmf_received(_dtmf("7"))
+    await asyncio.sleep(0.05)
+
+  assert len(captured) == 1
+  assert "7" in captured[0].parts[0].text
+
+
+async def test_partial_keypad_entry_is_not_forwarded_early():
+  """Digits still being typed must not wake the model mid-entry."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  lk_runner._queue.send_content = MagicMock()
+
+  lk_runner._on_dtmf_received(_dtmf("1"))
+  lk_runner._on_dtmf_received(_dtmf("2"))
+
+  lk_runner._queue.send_content.assert_not_called()
+
+
+async def test_a_keypad_entry_is_published_to_the_room():
+  """A keypress makes a turn nothing transcribes.
+
+  LiveKit relays the tones but not the turn assembled from them, so without
+  this a client shows the agent answering a question that appears nowhere.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  lk_runner._queue.send_content = MagicMock()
+
+  for digit in "4321#":
+    lk_runner._on_dtmf_received(_dtmf(digit))
+  await asyncio.gather(*lk_runner._forward_tasks)
+
+  (published,) = _chat_messages(room)
+  assert published.text == "4321#"
+  assert published.closed
+
+
+async def test_the_keypad_turn_is_attributed_to_the_caller():
+  """Otherwise the caller's own entry reads as something the agent said."""
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  lk_runner._queue.send_content = MagicMock()
+
+  lk_runner._on_dtmf_received(_dtmf("#", identity="sip_caller"))
+  await asyncio.gather(*lk_runner._forward_tasks)
+
+  assert _chat_messages(room)[0].sender_identity == "sip_caller"
+
+
+async def test_a_keypress_does_not_truncate_speech_being_transcribed():
+  """A caller can press a key while still talking.
+
+  Publishing onto their open transcription segment would close it, cutting
+  the caption short and stranding the rest of the sentence in a dead stream.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  lk_runner._queue.send_content = MagicMock()
+  await lk_runner._publish_transcript(
+      role=_livekit_runner._USER_ROLE, text="my account is ", final=False
+  )
+
+  lk_runner._on_dtmf_received(_dtmf("#"))
+  await asyncio.gather(*lk_runner._forward_tasks)
+
+  (interim,) = interim_transcripts(room)
+  assert not interim.closed  # Still open for the rest of the sentence.
+  assert _chat_messages(room)[0] is not interim
+
+
+async def test_a_failing_keypad_publish_does_not_end_the_call():
+  """Publishing the turn is cosmetic; the model still has to get the digits."""
+  room = make_room()
+  room.local_participant.stream_text = AsyncMock(
+      side_effect=RuntimeError("nope")
+  )
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  lk_runner._queue.send_content = MagicMock()
+
+  lk_runner._on_dtmf_received(_dtmf("#"))
+  await asyncio.gather(*lk_runner._forward_tasks)  # Must not raise.
+
+  lk_runner._queue.send_content.assert_called_once()
+
+
+# --- Waiting for the caller ---
+
+
+async def _registered_handler(room, event: str, tries: int = 50):
+  """Yields to the loop until `room.on(event, ...)` has been called."""
+  for _ in range(tries):
+    for call in room.on.call_args_list:
+      if call.args[0] == event:
+        return call.args[1]
+    await asyncio.sleep(0)
+  raise AssertionError(f"{event} was never subscribed to")
+
+
+async def test_the_session_waits_for_a_caller_before_it_starts():
+  """What a call can offer the model is read off the room.
+
+  A live connection fixes its tool declarations when it opens, so an agent
+  that starts before the caller arrives offers the wrong set for the whole
+  call -- on an outbound call, never the telephony tools.
+  """
+  room = make_room()  # Nobody has joined yet.
+  lk_runner = make_lk_runner(_make_runner([]), room, wait_for_participant=5)
+
+  session = asyncio.create_task(lk_runner.start())
+  on_connected = await _registered_handler(room, "participant_connected")
+  assert not session.done()
+
+  room.remote_participants = {"browser": MagicMock()}
+  on_connected(room.remote_participants["browser"])
+
+  await asyncio.wait_for(session, timeout=1)
+
+
+async def test_a_caller_already_in_the_room_is_not_waited_for():
+  """Inbound dispatch hands over a room that already holds the caller."""
+  room = make_room({"sip_caller": MagicMock()})
+  lk_runner = make_lk_runner(_make_runner([]), room, wait_for_participant=5)
+
+  await asyncio.wait_for(lk_runner.start(), timeout=1)
+
+
+async def test_the_session_starts_anyway_when_nobody_joins():
+  """An agent alone in a room is odd, not an error.
+
+  Failing here would turn a caller who hung up during the ring into a crashed
+  worker.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room, wait_for_participant=0.01)
+
+  await asyncio.wait_for(lk_runner.start(), timeout=1)
+
+
+async def test_the_wait_can_be_turned_off():
+  """Callers who join their own room know there is nobody to wait for."""
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room, wait_for_participant=None)
+
+  await asyncio.wait_for(lk_runner.start(), timeout=1)
+
+
+# --- Caller identity ---
+
+
+async def test_phone_call_seeds_the_caller_number_into_session_state():
+  """An agent should know who is calling before they say a word."""
+  participant = sip_participant({
+      "sip.phoneNumber": "+15105550100",
+      "sip.trunkPhoneNumber": "+15105550199",
+      "sip.callID": "call-1",
+  })
+  runner = _make_runner([])
+  runner.session_service.get_session = AsyncMock(return_value=None)
+  lk_runner = make_lk_runner(runner, make_room({"sip_caller": participant}))
+
+  await lk_runner._ensure_session()
+
+  state = runner.session_service.create_session.await_args.kwargs["state"]
+  assert state["livekit_caller_phone_number"] == "+15105550100"
+  assert state["livekit_called_phone_number"] == "+15105550199"
+  assert state["livekit_is_phone_call"] is True
+
+
+async def test_webrtc_call_adds_no_telephony_state():
+  """A browser caller has no phone number; state stays clean."""
+  runner = _make_runner([])
+  runner.session_service.get_session = AsyncMock(return_value=None)
+  lk_runner = make_lk_runner(runner, make_room())
+
+  await lk_runner._ensure_session()
+
+  assert runner.session_service.create_session.await_args.kwargs["state"] == {}
+
+
+async def test_late_sip_attributes_reach_the_running_session():
+  """Attributes mapped from SIP headers arrive after the participant does.
+
+  A second identical change is not resent, since that would append a junk
+  event to the session.
+  """
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  lk_runner = make_lk_runner(
+      _make_runner([]), make_room({"sip_caller": participant})
+  )
+  captured = []
+  lk_runner._queue.send = captured.append
+
+  lk_runner._on_participant_attributes({}, participant)
+  lk_runner._on_participant_attributes({}, participant)
+
+  assert len(captured) == 1
+  assert captured[0].state_delta["livekit_caller_phone_number"] == (
+      "+15105550100"
+  )
+
+
+async def test_the_agents_own_attributes_are_not_echoed_back():
+  """Publishing agent state fires this handler; that must not loop.
+
+  `lk.agent.state` is a participant attribute, so every listening/thinking/
+  speaking transition comes straight back as an attribute change on the local
+  participant.
+  """
+  participant = sip_participant({"sip.phoneNumber": "+15105550100"})
+  room = make_room({"sip_caller": participant})
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  captured = []
+  lk_runner._queue.send = captured.append
+
+  lk_runner._on_participant_attributes(
+      {"lk.agent.state": "speaking"}, room.local_participant
+  )
+
+  assert captured == []
+
+
+# --- Lifecycle ---
+
+
+async def test_start_closes_queue_when_session_ends():
+  """When run_live finishes, the live request queue is closed."""
+  lk_runner = make_lk_runner(_make_runner([]), make_room())
+  lk_runner._queue.close = MagicMock()
+
+  await lk_runner.start()
+
+  lk_runner._queue.close.assert_called_once()
+
+
+async def test_a_failed_setup_still_tears_the_call_down():
+  """A call that never starts must not leak the queue it would have used.
+
+  Setup fails before the event pump exists, so teardown has to cope with
+  there being nothing yet to cancel.
+  """
+  runner = _make_runner([])
+  runner.session_service.get_session = AsyncMock(return_value=None)
+  runner.session_service.create_session = AsyncMock(
+      side_effect=RuntimeError("session service is down")
+  )
+  lk_runner = make_lk_runner(runner, make_room())
+  lk_runner._queue.close = MagicMock()
+
+  with pytest.raises(RuntimeError, match="session service is down"):
+    await lk_runner.start()
+
+  lk_runner._queue.close.assert_called_once()
+
+
+async def test_the_room_is_released_when_the_session_ends():
+  """A room can outlive one call, and LiveKit allows one handler per topic.
+
+  Leaving the chat handler registered makes the *next* call fail rather than
+  this one, which is a miserable way to find out.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+
+  await lk_runner.start()
+
+  room.unregister_text_stream_handler.assert_called_once_with(
+      _livekit_runner.LK_CHAT_TOPIC
+  )
+  assert room.off.call_count == room.on.call_count
+
+
+async def test_forwarders_are_awaited_before_the_queue_closes():
+  """A forwarder's teardown still pushes onto the queue being closed.
+
+  `cancel()` only schedules cancellation, so a bridge that closes the queue
+  without awaiting its forwarders races its own shutdown.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_make_runner([]), room)
+  stopped = asyncio.Event()
+
+  async def _forwarder():
+    try:
+      await asyncio.Event().wait()
+    finally:
+      stopped.set()
+
+  lk_runner._forward_tasks.add(asyncio.create_task(_forwarder()))
+
+  await lk_runner.start()
+
+  assert stopped.is_set()
+
+
+async def test_room_disconnect_ends_the_session():
+  """Losing the room connection ends the call rather than hanging."""
+  lk_runner = make_lk_runner(_never_ending_runner(), make_room())
+
+  start = asyncio.create_task(lk_runner.start())
+  await asyncio.sleep(0)
+  lk_runner._on_disconnected()
+
+  await asyncio.wait_for(start, timeout=5)
+
+
+async def test_last_participant_leaving_ends_the_session():
+  """The caller hanging up is the end of the call.
+
+  A dispatched worker is torn down by LiveKit, but a room joined directly
+  would otherwise leave the agent alone in it holding a live model connection.
+  Closing the queue is not enough: with session resumption enabled `run_live`
+  reads that as a dropped connection and reconnects.
+  """
+  room = make_room()
+  lk_runner = make_lk_runner(_never_ending_runner(), room)
+
+  start = asyncio.create_task(lk_runner.start())
+  await asyncio.sleep(0)
+  lk_runner._on_participant_disconnected(MagicMock())
+
+  await asyncio.wait_for(start, timeout=5)
+
+
+async def test_session_survives_one_of_several_participants_leaving():
+  """Someone else leaving a multi-party room does not end the call."""
+  room = make_room({"still-here": MagicMock(track_publications={})})
+  lk_runner = make_lk_runner(_never_ending_runner(), room)
+
+  start = asyncio.create_task(lk_runner.start())
+  await asyncio.sleep(0)
+  lk_runner._on_participant_disconnected(MagicMock())
+
+  with pytest.raises(asyncio.TimeoutError):
+    await asyncio.wait_for(asyncio.shield(start), timeout=0.2)
+  start.cancel()
+  with contextlib.suppress(asyncio.CancelledError):
+    await start
+
+
+# --- Sessions ---
+
+
+def _real_runner(app_name: str = "dice") -> InMemoryRunner:
+  """A real Runner whose agent answers over a mocked live connection."""
+  model = MockModel.create(responses=["you rolled a four"])
+  return InMemoryRunner(
+      agent=LlmAgent(name="dice_agent", model=model), app_name=app_name
+  )
+
+
+async def test_session_created_for_a_new_room():
+  """A freshly joined room has no ADK session, so the connector makes one.
+
+  `Runner` raises `SessionNotFoundError` rather than creating sessions, so a
+  brand new room used to fail before a single frame moved.
+  """
+  runner = _real_runner()
+  lk_runner = make_lk_runner(runner, make_room())
+
+  await lk_runner._ensure_session()
+
+  assert (
+      await runner.session_service.get_session(
+          app_name="dice", user_id="u1", session_id="s1"
+      )
+      is not None
+  )
+
+
+async def test_existing_session_is_reused():
+  """An out-of-band session is picked up, not replaced."""
+  runner = _real_runner()
+  created = await runner.session_service.create_session(
+      app_name="dice", user_id="u1", session_id="s1"
+  )
+  lk_runner = make_lk_runner(runner, make_room())
+
+  await lk_runner._ensure_session()
+
+  found = await runner.session_service.get_session(
+      app_name="dice", user_id="u1", session_id="s1"
+  )
+  assert found.id == created.id
+
+
+async def test_concurrent_create_is_tolerated():
+  """Two workers on the same room must not fight over creating its session.
+
+  Dispatch retries and rejoins both land two callers on one room; the loser of
+  the create race should carry on with the session the winner made.
+  """
+  runner = _real_runner()
+  lk_runner = make_lk_runner(runner, make_room())
+  real_create = runner.session_service.create_session
+
+  async def _create_then_conflict(**kwargs):
+    await real_create(**kwargs)  # the other worker won
+    raise AlreadyExistsError("Session with id s1 already exists.")
+
+  runner.session_service.create_session = _create_then_conflict
+
+  await lk_runner._ensure_session()  # must not raise
+
+  assert (
+      await runner.session_service.get_session(
+          app_name="dice", user_id="u1", session_id="s1"
+      )
+      is not None
+  )
+
+
+async def test_create_session_can_be_opted_out():
+  """With create_session False the caller owns session lifecycle."""
+  runner = _real_runner()
+  lk_runner = make_lk_runner(runner, make_room(), create_session=False)
+
+  with pytest.raises(SessionNotFoundError):
+    await lk_runner.start()
+
+  assert (
+      await runner.session_service.get_session(
+          app_name="dice", user_id="u1", session_id="s1"
+      )
+      is None
+  )
+
+
+async def test_room_drives_a_real_run_live():
+  """End to end: a connected room reaches the model over a real Runner."""
+  runner = _real_runner()
+  room = make_room()
+  lk_runner = make_lk_runner(runner, room)
+
+  # `run_live` stays open for the life of the call, so stop it once the
+  # session is established and the outbound track is published.
+  task = asyncio.create_task(lk_runner.start())
+  try:
+    session = await _await_session(runner, "dice", "u1", "s1")
+    assert session is not None, _failure_of(task) or (
+        "run_live never created the room's session."
+    )
+  finally:
+    task.cancel()
+    with contextlib.suppress(asyncio.CancelledError):
+      await task
+
+  room.local_participant.publish_track.assert_awaited_once()
+
+
+async def _await_session(runner: Runner, app_name: str, user_id: str, sid: str):
+  """Polls the runner's session service until the session shows up."""
+  for _ in range(100):
+    session = await runner.session_service.get_session(
+        app_name=app_name, user_id=user_id, session_id=sid
+    )
+    if session is not None:
+      return session
+    await asyncio.sleep(0.05)
+  return None
+
+
+def _failure_of(task: asyncio.Task) -> str:
+  """Renders a task's exception, so a crashed session reports its own cause."""
+  if not task.done() or task.cancelled():
+    return ""
+  exc = task.exception()
+  return f"Entrypoint raised {type(exc).__name__}: {exc}" if exc else ""
+
+
+# --- The optional dependency ---
+
+
+def test_using_the_connector_without_the_extra_names_it():
+  """A missing `livekit` must say which extra installs it.
+
+  Run in a fresh interpreter: blocking an import is process-global, and the
+  rest of this file depends on the real SDK being importable.
+  """
+  result = run_isolated("""
+import sys
+
+
+class _NoLiveKit:
+
+  def find_spec(self, name, path=None, target=None):
+    if name == 'livekit' or name.startswith('livekit.'):
+      raise ImportError('livekit is not installed')
+    return None
+
+
+sys.meta_path.insert(0, _NoLiveKit())
+
+# The package itself imports lazily, so this much works with no SDK present.
+import google.adk.integrations.livekit as livekit_integration
+
+try:
+  livekit_integration.LiveKitRunner
+except ImportError as e:
+  assert 'google-adk[livekit]' in str(e), str(e)
+else:
+  raise AssertionError('Expected an ImportError naming the extra.')
+""")
+
+  assert result.returncode == 0, result.stderr
diff --git a/tests/unittests/integrations/livekit/test_livekit_workflow.py b/tests/unittests/integrations/livekit/test_livekit_workflow.py
new file mode 100644
index 00000000000..b01f64eb5bb
--- /dev/null
+++ b/tests/unittests/integrations/livekit/test_livekit_workflow.py
@@ -0,0 +1,133 @@
+# Copyright 2026 Google LLC
+#
+# 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.
+
+"""Tests for driving a multi-stage `Workflow` over a LiveKit room.
+
+A `Workflow` root takes a different path through `Runner.run_live` than a
+plain agent -- a producer task feeding an event queue rather than an inline
+generator -- and opens one model connection per stage. Everything the bridge
+does with the resulting events is covered in `test_livekit_runner.py`; what is
+left to check is that it still sees them across that boundary.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import json
+
+from google.adk.agents.llm_agent import LlmAgent
+from google.adk.agents.run_config import RunConfig
+from google.adk.models.llm_response import LlmResponse
+from google.adk.runners import InMemoryRunner
+from google.adk.workflow import START
+from google.adk.workflow import Workflow
+from google.genai import types
+import pytest
+
+pytest.importorskip("livekit")
+
+from tests.unittests.integrations.livekit.conftest import final_transcripts
+from tests.unittests.integrations.livekit.conftest import make_lk_runner
+from tests.unittests.integrations.livekit.conftest import make_room
+from tests.unittests.testing_utils import MockModel
+
+
+def _stage(name: str, *, says: str) -> LlmAgent:
+  """One task-mode live stage that speaks once, then completes.
+
+  A real live model reports its own speech as a transcription rather than as
+  text content, so the stage emits the fragments and the finished utterance
+  the way `gemini_llm_connection` does -- otherwise nothing would exercise the
+  caption path.
+  """
+  head, tail = says[: len(says) // 2], says[len(says) // 2 :]
+  model = MockModel.create(
+      responses=[
+          LlmResponse(
+              partial=True,
+              output_transcription=types.Transcription(
+                  text=head, finished=False
+              ),
+          ),
+          LlmResponse(
+              partial=True,
+              output_transcription=types.Transcription(
+                  text=tail, finished=False
+              ),
+          ),
+          LlmResponse(
+              partial=False,
+              output_transcription=types.Transcription(
+                  text=says, finished=True
+              ),
+          ),
+          LlmResponse(
+              content=types.Content(
+                  role="model",
+                  parts=[
+                      types.Part.from_function_call(
+                          name="finish_task", args={"result": f"{name}_done"}
+                      )
+                  ],
+              )
+          ),
+      ]
+  )
+  return LlmAgent(name=name, model=model, mode="task", instruction=name)
+
+
+async def test_every_stage_of_a_workflow_reaches_the_room():
+  """A three-stage call runs to completion and each stage is heard.
+
+  Setup: a greet -> verify -> deliver workflow, each stage speaking once and
+    then calling `finish_task`. The run_config replaces the connector's
+    default wholesale, so transcription is set explicitly.
+  Act: run the session to completion.
+  Assert: all three stages ran, and each published its own caption segment
+    rather than extending the previous stage's.
+  """
+  room = make_room()
+  workflow = Workflow(
+      name="care_call",
+      edges=[
+          (START, greeter := _stage("greeter", says="Am I speaking with Jo?")),
+          (greeter, verifier := _stage("verifier", says="Date of birth?")),
+          (verifier, _stage("goals", says="Your appointment is Tuesday.")),
+      ],
+  )
+  lk_runner = make_lk_runner(
+      InMemoryRunner(agent=workflow, app_name="care_call"),
+      room,
+      run_config=RunConfig(
+          response_modalities=[types.Modality.AUDIO],
+          input_audio_transcription=types.AudioTranscriptionConfig(),
+          output_audio_transcription=types.AudioTranscriptionConfig(),
+      ),
+  )
+
+  await asyncio.wait_for(lk_runner.start(), timeout=10)
+
+  finished = [
+      json.loads(call.args[0])["name"]
+      for call in room.local_participant.publish_data.await_args_list
+      if json.loads(call.args[0])["type"] == "function_call"
+  ]
+  assert finished.count("finish_task") == 3
+  captions = final_transcripts(room)
+  assert [writer.text for writer in captions] == [
+      "Am I speaking with Jo?",
+      "Date of birth?",
+      "Your appointment is Tuesday.",
+  ]
+  assert len({writer.attributes["lk.segment_id"] for writer in captions}) == 3