Rename BaseTask → BaseWorker and reserve "task" for asyncio

Replaces every "task" identifier that referred to the BaseTask
abstraction with "worker". Asyncio task plumbing (asyncio.Task,
BaseTaskManager, TaskManager, create_task, cancel_task, etc.) stays
untouched. Highlights:

- Classes: BaseTask → BaseWorker, PipelineTask → PipelineWorker,
  LLMTask → LLMWorker, LLMContextTask → LLMContextWorker, TaskBus →
  WorkerBus, TaskRegistry → WorkerRegistry, TaskActivationArgs →
  WorkerActivationArgs, TaskReadyData → WorkerReadyData,
  TaskRegistryEntry → WorkerRegistryEntry, TaskObserver →
  WorkerObserver, all Bus*TaskMessage → Bus*WorkerMessage,
  BusAddTaskMessage.task field → worker, BusWorkerRegistryMessage.tasks
  field → workers.
- Methods/decorators: activate_task → activate_worker, deactivate_task
  → deactivate_worker, add_task → add_worker, watch_task →
  watch_worker, @task_ready → @worker_ready, setup_pipeline_task hook
  → setup_pipeline_worker.
- Params/fields: FrameProcessorSetup.pipeline_task and
  FunctionCallParams.pipeline_task → pipeline_worker. Parameter names
  like task_name → worker_name; spawn/run accept worker:.
- Files: pipeline/base_task.py → base_worker.py, pipeline/task.py →
  worker.py (plus a re-export shim at pipeline/task.py),
  task_observer.py → worker_observer.py, task_ready_decorator.py →
  worker_ready_decorator.py, pipecat.tasks → pipecat.workers,
  llm_task.py → llm_worker.py, llm_context_task.py →
  llm_context_worker.py, examples/multi-task → examples/multi-worker.

Back-compat:
- PipelineTask kept as a deprecated subclass of PipelineWorker that
  warns on construction.
- pipecat.pipeline.task re-exports PipelineWorker/PipelineTask/etc. so
  existing user imports keep working.
- FrameProcessor.pipeline_task kept as a deprecated property that
  forwards to pipeline_worker.

Local variables in examples that hold a worker (task = PipelineTask(...))
are renamed to worker = PipelineWorker(...). Asyncio-task locals
(runner_task, etc.) are preserved.
This commit is contained in:
Aleix Conchillo Flaqué
2026-05-20 16:39:45 -07:00
parent b9aed0d673
commit b03247f360
394 changed files with 4602 additions and 4487 deletions

View File

@@ -0,0 +1,369 @@
# Pipecat Multi-Worker Examples
This directory contains example bots that use the multi-worker framework in `pipecat.workers`, `pipecat.pipeline.runner` (with `add_worker()`), and the `WorkerBus`. Each example shows a different cooperation pattern between workers: hand-off, parallel fan-out, remote workers, etc.
## Setup
From the repo root:
```bash
uv sync --all-extras
source .venv/bin/activate
cd examples/multi-worker
```
Copy the env template and fill in your API keys:
```bash
cp env.example .env
```
## Environment variables
| Variable | Required by |
| ------------------ | --------------------------------------- |
| `OPENAI_API_KEY` | LLM workers |
| `DEEPGRAM_API_KEY` | STT |
| `CARTESIA_API_KEY` | TTS |
| `DAILY_API_KEY` | Optional: only with `--transport daily` |
Additional, example-specific variables are listed below.
## Table of contents
**[Local](#local)** (single process)
- [Handoff between LLM workers](#handoff-between-llm-tasks)
- [Parallel debate](#parallel-debate)
- [Voice code assistant with Claude Agent SDK](#voice-code-assistant)
- [Sensor controller](#sensor-controller)
**[Distributed](#distributed)** (multi-process)
- [Handoff via Redis](#handoff-via-redis)
- [Handoff via PGMQ (Postgres)](#handoff-via-pgmq-postgres)
- [LLM worker via WebSocket proxy](#llm-task-via-websocket-proxy)
# Local
Examples where all workers run in the same process on an `AsyncQueueBus`.
## Handoff between LLM workers
Two LLM workers (greeter + support) that transfer control to each other during a voice conversation. A main worker owns the transport pipeline and bridges frames to the bus.
### Running
```bash
uv run local-handoff/local-handoff-two-agents.py
```
Open <http://localhost:7860/client> in your browser to talk to your bot.
To use Daily transport:
```bash
uv run local-handoff/local-handoff-two-agents.py --transport daily
```
### Overview
- **[`local-handoff-two-agents.py`](local-handoff/local-handoff-two-agents.py)** — Two LLM workers (greeter + support) that hand off via `activate_worker(..., deactivate_self=True)`. The main worker owns STT, TTS, transport, and a `BusBridgeProcessor`.
- **[`local-handoff-two-agents-tts.py`](local-handoff/local-handoff-two-agents-tts.py)** — Same shape, but each child worker ships with its own `CartesiaTTSService` in a custom pipeline. The main worker has no TTS — audio comes from whichever child is active over the bus.
## Parallel debate
Parallel fan-out using `worker.job_group(...)`. A voice bot takes a topic from the user, kicks off three workers in parallel (advocate, critic, analyst), waits for all three to respond, and synthesizes a balanced answer. Each worker keeps its own LLM context across rounds.
### Running
```bash
uv run parallel-debate/parallel-debate.py
```
Open <http://localhost:7860/client> in your browser to talk to your bot.
To use Daily transport:
```bash
uv run parallel-debate/parallel-debate.py --transport daily
```
### Architecture
```
Main worker (transport + LLM + `debate` tool)
└── job_group(advocate, critic, analyst)
└── DebateWorker (LLMContextWorker, one per role)
```
- **Main worker**: transport (STT, TTS) + LLM moderator with a `debate` direct function that fans out via `worker.job_group(...)`.
- **Debate workers**: `LLMContextWorker`s spawned on the runner. Each keeps its own `LLMContext` across rounds and ships its completed turn back as a job response via the assistant-aggregator's `on_assistant_turn_stopped` event.
## Voice code assistant
Talk to your codebase hands-free. Ask questions about code, project structure, or file contents and get spoken answers based on actual files. The Claude Agent SDK worker navigates the filesystem using `Read`, `Bash`, `Glob`, and `Grep` tools.
### Additional environment variables
| Variable | Required by |
| ------------------- | ------------------------------ |
| `ANTHROPIC_API_KEY` | Code worker (Claude Agent SDK) |
| `PROJECT_PATH` | Optional, defaults to cwd |
### Running
```bash
# Default: explores the current directory
uv run code-assistant/code-assistant.py
# Specify a project path
PROJECT_PATH=/path/to/your/project uv run code-assistant/code-assistant.py
```
Open <http://localhost:7860/client> in your browser to talk to your bot.
To use Daily transport:
```bash
uv run code-assistant/code-assistant.py --transport daily
```
### Example questions
- "What does the main module do?"
- "Find all TODO comments in the project"
- "How is error handling implemented?"
- "What dependencies does this project use?"
- "Explain the test structure"
### Architecture
```
Main worker (transport + LLM + `ask_code` tool)
└── job → CodeWorker (Claude Agent SDK)
```
- **`code-assistant.py`** — Main worker: STT, LLM (with system prompt + `ask_code` direct function), TTS, and transport. The `ask_code` tool dispatches a job to the worker via `worker.job("code_worker", payload=...)`.
- **`code_worker.py`** — `CodeWorker`: a bus-only `BaseWorker` spawned on the runner. It accepts `@job`-style requests through the bus and runs them sequentially through a persistent Claude SDK session so follow-up questions share context.
## Sensor controller
Two `PipelineWorker`s side by side, communicating only over job RPC. A voice agent has a single `ask_controller(question)` tool that forwards every temperature-related request to a worker; the worker owns a simulated thermometer and its own tool-calling LLM that decides how to answer (read the current value, inspect rolling stats, change the target, change the response rate). The worker is a plain `PipelineWorker` — it does not subclass `LLMWorker` and is not bridged.
### Running
```bash
uv run sensor-controller/sensor-controller.py
```
Open <http://localhost:7860/client> in your browser to talk to your bot.
To use Daily transport:
```bash
uv run sensor-controller/sensor-controller.py --transport daily
```
### Example questions
- "What's the temperature?"
- "Make it warmer."
- "Is it stable yet?"
- "Why is it slow?" / "Speed up the response."
- "What was the highest reading?"
### Architecture
```
Voice agent (transport + STT + LLM + TTS, tool: ask_controller)
└── job → Controller (PipelineWorker)
└── SensorReader -> SensorStats -> user_agg -> llm -> assistant_agg
```
- **[`sensor-controller.py`](sensor-controller/sensor-controller.py)** — `build_sensor_controller()` returns a plain `PipelineWorker`. Jobs arrive via `@worker.event_handler("on_job_request")`, the question is queued onto the worker LLM, and the LLM's reply is paired back to the job via the assistant aggregator's `on_assistant_turn_stopped` event.
- **[`sensor.py`](sensor-controller/sensor.py)** — Two custom `FrameProcessor` subclasses: `SensorReader` runs an autonomous tick loop that emits a `SensorReadingFrame` each second (first-order lag toward target plus Gaussian noise; mutable target and response rate); `SensorStats` maintains rolling min/max/avg/trend.
# Distributed
Examples where workers run across separate processes or machines.
## Handoff via Redis
Same two-worker handoff as the local example, but each worker runs as a separate process connected via Redis pub/sub. Requires `pip install pipecat-ai[redis]`.
### Quick start (single machine, local Redis)
_Terminal 1_: start Redis
```bash
docker run --rm -p 6379:6379 redis:7
```
_Terminal 2_: start the greeter worker
```bash
uv run distributed-handoff/redis-handoff/llm.py greeter
```
_Terminal 3_: start the support worker
```bash
uv run distributed-handoff/redis-handoff/llm.py support
```
_Terminal 4_: start the main transport worker
```bash
uv run distributed-handoff/redis-handoff/main.py
```
All processes connect to `redis://localhost:6379` by default.
### Running across machines
Point each process at the same Redis instance:
_Machine A_
```bash
uv run distributed-handoff/redis-handoff/main.py --redis-url redis://your-redis-host:6379
```
_Machine B_
```bash
uv run distributed-handoff/redis-handoff/llm.py greeter --redis-url redis://your-redis-host:6379
```
_Machine C_
```bash
uv run distributed-handoff/redis-handoff/llm.py support --redis-url redis://your-redis-host:6379
```
### Architecture
```
Machine A Redis Machine B
+------------+ +-------------+ +-------------+
| main.py | <----> | pub/sub | <----> | llm.py |
| (transport,| | channel: | | (greeter) |
| STT, TTS) | | pipecat:acme| +-------------+
+------------+ +-------------+ +-------------+
^ | llm.py |
+--------------> | (support) |
+-------------+
```
- **[main.py](distributed-handoff/redis-handoff/main.py)** — Transport worker: Daily/WebRTC, Deepgram STT, Cartesia TTS, and a `BusBridgeProcessor` over a `RedisBus`.
- **[llm.py](distributed-handoff/redis-handoff/llm.py)** — LLM worker: runs either `greeter` or `support` with OpenAI behind a bridged `LLMWorker`.
## Handoff via PGMQ (Postgres)
Same shape as the Redis handoff, but the bus is backed by [PGMQ](https://github.com/tembo-io/pgmq) on a shared Postgres database (e.g. Supabase). Requires `pip install pipecat-ai[pgmq]`.
### Additional environment variables
| Variable | Required by |
| -------------- | -------------------------------------------------------------------- |
| `DATABASE_URL` | PostgreSQL DSN (e.g. Supabase pooled connection string) |
| `PGMQ_CHANNEL` | Optional, channel prefix for queue names. Defaults to `pipecat_acme` |
### Quick start
_Terminal 1_: start the greeter worker
```bash
uv run distributed-handoff/pgmq-handoff/llm.py greeter --database-url $DATABASE_URL
```
_Terminal 2_: start the support worker
```bash
uv run distributed-handoff/pgmq-handoff/llm.py support --database-url $DATABASE_URL
```
_Terminal 3_: start the main transport worker
```bash
uv run distributed-handoff/pgmq-handoff/main.py --database-url $DATABASE_URL
```
You can also set `DATABASE_URL` in `.env` and omit the `--database-url` flag.
### Architecture
Same as the Redis handoff above; the `RedisBus` is replaced by a `PgmqBus`, and the "pub/sub channel" is a set of PGMQ queues on the shared Postgres instance.
## LLM worker via WebSocket proxy
Runs an LLM worker on a remote server, connected to the main transport worker via a WebSocket proxy. No shared bus required — the proxy workers forward bus messages point-to-point over the WebSocket.
### Quick start (single machine)
_Terminal 1_: start the remote assistant server
```bash
uv run remote-proxy-assistant/assistant.py
```
_Terminal 2_: start the main transport worker
```bash
uv run remote-proxy-assistant/main.py --remote-url ws://localhost:8765/ws
```
Open <http://localhost:7860/client> in your browser to talk to the bot.
### Running across machines
_Server machine_: start the assistant
```bash
uv run remote-proxy-assistant/assistant.py --host 0.0.0.0 --port 8765
```
_Client machine_: point at the server
```bash
uv run remote-proxy-assistant/main.py --remote-url ws://server-host:8765/ws
```
### Architecture
```
+-------------+ +-------------+ +-------------+ +-----------------+
| | | | | | | |
| Main worker | | Proxy worker | <~~~~~> | Proxy worker | | Assistant worker |
| | | (client) | | (server) | | |
+-------------+ +-------------+ +-------------+ +-----------------+
messages messages messages messages
│ │ │ │
══════════╧═════════════════╧════════ ════════╧════════════════════╧═══════════
Task Bus Task Bus
═════════════════════════════════════ ═════════════════════════════════════════
```
- **[main.py](remote-proxy-assistant/main.py)** — Transport worker with STT, TTS, and a `BusBridge`. Spawns a `WebSocketProxyClientTask` that connects to the remote server and forwards `BusFrameMessage`s.
- **[assistant.py](remote-proxy-assistant/assistant.py)** — FastAPI server. Each WebSocket connection spawns a `WebSocketProxyServerTask` plus a bridged `AcmeAssistant` LLM worker on a per-session `PipelineRunner`.
### Security
The proxy workers filter messages by worker name:
- Only messages targeted at the remote worker cross the WebSocket
- Only messages targeted at the local worker are accepted from the WebSocket
- Broadcast messages never cross the WebSocket
Pass HTTP headers for authentication:
```python
proxy = WebSocketProxyClientTask(
"proxy",
url="wss://server-host:8765/ws",
remote_worker_name="assistant",
local_worker_name="acme",
headers={"Authorization": "Bearer <token>"},
)
```

View File

@@ -0,0 +1,179 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Voice code assistant powered by Claude Agent SDK.
Talk to your codebase hands-free. Ask questions like "what does the
auth middleware do?" or "find all TODO comments" and get spoken answers
based on actual file contents. The Claude Agent SDK worker navigates
the filesystem using Read, Bash, Glob, and Grep tools.
Architecture::
Main worker (transport + LLM + ``ask_code`` tool)
└── job → CodeWorker (Claude Agent SDK)
Requirements:
- ANTHROPIC_API_KEY
- OPENAI_API_KEY
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DAILY_API_KEY (for Daily transport)
"""
import os
from code_worker import CodeWorker
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.frames.frames import LLMMessagesAppendFrame, LLMRunFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
load_dotenv(override=True)
PROJECT_PATH = os.getenv("PROJECT_PATH", os.getcwd())
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
async def ask_code(params: FunctionCallParams, question: str):
"""Ask a question about the codebase. A Claude Code worker will
explore the project by reading files, searching code, and running
commands. It remembers previous questions for follow-ups.
Args:
question (str): The question about code, files, structure,
dependencies, or anything in the project.
"""
logger.info(f"Asking code worker: '{question}'")
async with params.pipeline_worker.job("code_worker", payload={"question": question}) as job:
await params.llm.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "developer", "content": "Give me a moment."}],
run_llm=True,
)
)
# The LLM keeps talking while the worker runs.
await params.result_callback(job.response)
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info("Starting code assistant")
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a voice interface to a code assistant powered by Claude Code. "
"Behind you is a worker that can read files, search code with grep and "
"glob patterns, and run bash commands on the project. It maintains "
"context across questions, so follow-up questions work naturally.\n\n"
"When the user asks anything about code, project structure, files, "
"dependencies, tests, or wants to explore the codebase, call the "
"ask_code tool. When the worker result comes back, summarize it naturally "
"for speaking. Keep responses concise and conversational.\n"
),
),
)
llm.register_direct_function(ask_code, cancel_on_interruption=False, timeout_secs=60)
context = LLMContext(tools=ToolsSchema(standard_tools=[ask_code]))
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
llm,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
context.add_message(
{
"role": "developer",
"content": "Greet the user and tell them you're a code assistant.",
}
)
await worker.queue_frame(LLMRunFrame())
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
await runner.add_worker(CodeWorker("code_worker", project_path=PROJECT_PATH))
await runner.add_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -0,0 +1,118 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Code worker that explores a codebase using Claude Agent SDK."""
import asyncio
from loguru import logger
from pipecat.bus import BusJobRequestMessage
from pipecat.pipeline.base_worker import BaseWorker
from pipecat.pipeline.job_context import JobStatus
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
except ModuleNotFoundError as e:
logger.error(f"Exception: {e}")
logger.error("In order to use CodeWorker, you need to `pip install claude-agent-sdk`.")
raise Exception(f"Missing module: {e}")
class CodeWorker(BaseWorker):
"""Bus-only worker that answers code questions using Claude Agent SDK.
Maintains a persistent Claude SDK session so follow-up questions
share context. Questions are queued and processed sequentially. The
worker has no Pipecat pipeline — it consumes job requests from the
bus and replies with job responses.
"""
def __init__(self, name: str, *, project_path: str):
"""Initialize the CodeWorker.
Args:
name: Unique worker name.
project_path: Filesystem path the Claude SDK should explore.
"""
super().__init__(name)
self._project_path = project_path
self._queue: asyncio.Queue = asyncio.Queue()
self._worker_task: asyncio.Task | None = None
self._claude_options = ClaudeAgentOptions(
permission_mode="bypassPermissions",
system_prompt=(
f"You are a code assistant. The project is at: {self._project_path}\n\n"
"Answer the user's question by exploring the codebase. Use Read to "
"view files, Glob to find files by pattern, and Bash to run commands "
"like grep or find. Be thorough but concise in your answer. "
"Focus on what the user asked. Respond with a clear, spoken-friendly "
"summary (no markdown, no bullet points, no code blocks)."
),
allowed_tools=["Read", "Bash", "Glob", "Grep"],
model="sonnet",
max_turns=10,
)
async def start(self) -> None:
"""Launch the Claude SDK worker loop alongside the standard worker start."""
await super().start()
self._worker_task = self.create_task(self._worker_loop(), "worker")
async def stop(self) -> None:
"""Cancel the worker loop before tearing down the worker."""
if self._worker_task:
await self.cancel_task(self._worker_task)
self._worker_task = None
await super().stop()
async def on_job_request(self, message: BusJobRequestMessage) -> None:
"""Enqueue an incoming job for the worker loop."""
await super().on_job_request(message)
logger.info(f"Worker '{self.name}': queued '{message.payload['question']}'")
self._queue.put_nowait(message)
async def _worker_loop(self):
client = ClaudeSDKClient(options=self._claude_options)
try:
await client.connect()
except Exception as e:
logger.error(f"Worker '{self.name}': failed to start Claude SDK: {e}")
return
try:
while True:
message = await self._queue.get()
question = message.payload["question"]
logger.info(f"Worker '{self.name}': researching '{question}'")
try:
answer = ""
await client.query(prompt=question)
async for msg in client.receive_response():
if type(msg).__name__ == "AssistantMessage":
for block in msg.content:
if type(block).__name__ == "TextBlock":
answer += block.text
logger.info(f"Worker '{self.name}': completed ({len(answer)} chars)")
await self.send_job_response(message.job_id, {"answer": answer})
except Exception as e:
logger.error(f"Worker '{self.name}': error: {e}")
await self.send_job_response(
message.job_id, {"error": str(e)}, status=JobStatus.ERROR
)
finally:
# Bypass `async with ClaudeSDKClient` and call disconnect()
# ourselves: __aexit__ → Query.close() → _read_task.wait() uses
# `with suppress(asyncio.CancelledError)`, which would swallow the
# outer task's cancellation. By the time this finally runs, our
# CancelledError has already been raised once, so _must_cancel is
# cleared and disconnect()'s awaits proceed normally.
await client.disconnect()

View File

@@ -0,0 +1,182 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""LLM worker — run on Machine B (or locally alongside ``main.py``).
A standalone process that runs one LLM worker (greeter or support)
attached to the same PGMQ-backed `WorkerBus` as the main worker.
Multiple instances can run on different machines as long as they
share a Postgres database with the PGMQ extension enabled.
Usage::
python llm.py greeter --database-url postgresql://...
python llm.py support --database-url postgresql://...
Requirements:
- OPENAI_API_KEY
- DATABASE_URL (or ``--database-url``)
"""
import argparse
import asyncio
import os
from urllib.parse import unquote, urlparse
from dotenv import load_dotenv
from loguru import logger
from pgmq.async_queue import PGMQueue
from pipecat.bus.network.pgmq import PgmqBus
from pipecat.pipeline.runner import PipelineRunner
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.workers.llm import LLMWorker, LLMWorkerActivationArgs, tool
load_dotenv(override=True)
WORKER_CONFIG = {
"greeter": {
"system_instruction": (
"You are a friendly greeter for Acme Corp. The available products "
"are: the Acme Rocket Boots, the Acme Invisible Paint, and the Acme "
"Tornado Kit. Ask which one they'd like to learn more about. "
"When the user picks a product or asks a question about one, "
"immediately call the transfer_to_agent tool with agent 'support'. "
"Do not answer product questions yourself. If the user says goodbye, "
"call the end_conversation tool. Do not mention transferring — just do it "
"seamlessly. Keep responses brief — this is a voice conversation."
),
"watch": ["support"],
},
"support": {
"system_instruction": (
"You are a support agent for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up "
"to 60 mph), Acme Invisible Paint (makes anything invisible for "
"24 hours, $49 per can), and Acme Tornado Kit (portable tornado "
"generator, $199, batteries included). Answer the user's questions "
"about these products. If the user wants to browse other products "
"or start over, call the transfer_to_agent tool with agent "
"'greeter'. If the user says goodbye, call the end_conversation "
"tool. Do not mention transferring — just do it seamlessly. "
"Keep responses brief — this is a voice conversation."
),
"watch": ["greeter"],
},
}
def pgmq_from_url(database_url: str, *, pool_size: int = 4) -> PGMQueue:
"""Build a `PGMQueue` from a Postgres DSN string."""
parsed = urlparse(database_url)
if parsed.scheme not in ("postgres", "postgresql"):
raise ValueError(f"Unsupported scheme '{parsed.scheme}' for database URL")
return PGMQueue(
host=parsed.hostname or "localhost",
port=str(parsed.port or 5432),
database=(parsed.path or "/postgres").lstrip("/") or "postgres",
username=unquote(parsed.username or "postgres"),
password=unquote(parsed.password or ""),
pool_size=pool_size,
)
class AcmeLLMTask(LLMWorker):
"""LLM worker for Acme Corp with transfer and end tools."""
def __init__(self, name: str, *, system_instruction: str, watch: list[str]):
"""Initialize the AcmeLLMTask.
Args:
name: Unique worker name (``"greeter"`` or ``"support"``).
system_instruction: System prompt for this LLM role.
watch: Sibling worker names this worker will watch via the
registry so it knows when they become available for
handoff.
"""
llm = OpenAILLMService(
name=f"{name}::OpenAILLMService",
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=system_instruction),
)
super().__init__(name, llm=llm, bridged=())
self._watch = watch
async def start(self) -> None:
"""Register watches for sibling workers once ready."""
await super().start()
for worker_name in self._watch:
await self.watch_worker(worker_name)
@tool(cancel_on_interruption=False)
async def transfer_to_agent(self, params: FunctionCallParams, agent: str, reason: str):
"""Transfer the user to another agent.
Args:
agent (str): The agent to transfer to (e.g. 'greeter', 'support').
reason (str): Why the user is being transferred.
"""
logger.info(f"Task '{self.name}': transferring to '{agent}' ({reason})")
await self.activate_worker(
agent,
args=LLMWorkerActivationArgs(messages=[{"role": "developer", "content": reason}]),
deactivate_self=True,
result_callback=params.result_callback,
)
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
async def main_async() -> None:
parser = argparse.ArgumentParser(description="LLM worker (greeter or support)")
parser.add_argument("worker", choices=list(WORKER_CONFIG), help="Which worker to run")
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL"),
help="PostgreSQL DSN (or set DATABASE_URL env var)",
)
parser.add_argument(
"--channel",
default=os.getenv("PGMQ_CHANNEL", "pipecat_acme"),
help="PGMQ channel prefix",
)
args = parser.parse_args()
if not args.database_url:
parser.error("--database-url is required (or set DATABASE_URL env var)")
pgmq = pgmq_from_url(args.database_url)
await pgmq.init()
bus = PgmqBus(pgmq=pgmq, channel=args.channel)
config = WORKER_CONFIG[args.worker]
worker = AcmeLLMTask(
args.worker,
system_instruction=config["system_instruction"],
watch=config["watch"],
)
runner = PipelineRunner(bus=bus, handle_sigint=True)
logger.info(f"Starting {args.worker} worker, waiting for activation...")
await runner.run(worker)
if __name__ == "__main__":
asyncio.run(main_async())

View File

@@ -0,0 +1,196 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Main transport worker — run on Machine A.
Handles audio I/O (STT, TTS) and bridges frames to the bus. The LLM
workers run as separate processes (possibly on different
machines) connected via PGMQ on a shared Postgres database
(e.g. Supabase).
Usage::
python main.py --database-url postgresql://...
Requirements:
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DATABASE_URL (or ``--database-url``)
"""
import argparse
import os
from urllib.parse import unquote, urlparse
from dotenv import load_dotenv
from loguru import logger
from pgmq.async_queue import PGMQueue
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor
from pipecat.bus.network.pgmq import PgmqBus
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.registry.types import WorkerReadyData
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.llm import LLMWorkerActivationArgs
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
def pgmq_from_url(database_url: str, *, pool_size: int = 4) -> PGMQueue:
"""Build a `PGMQueue` from a Postgres DSN string."""
parsed = urlparse(database_url)
if parsed.scheme not in ("postgres", "postgresql"):
raise ValueError(f"Unsupported scheme '{parsed.scheme}' for database URL")
return PGMQueue(
host=parsed.hostname or "localhost",
port=str(parsed.port or 5432),
database=(parsed.path or "/postgres").lstrip("/") or "postgres",
username=unquote(parsed.username or "postgres"),
password=unquote(parsed.password or ""),
pool_size=pool_size,
)
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
pgmq = pgmq_from_url(runner_args.cli_args.database_url)
await pgmq.init()
bus = PgmqBus(pgmq=pgmq, channel=runner_args.cli_args.channel)
runner = PipelineRunner(bus=bus, handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
bridge = BusBridgeProcessor(
bus=runner.bus,
worker_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
# The remote LLM workers may take a moment to register on the bus.
# We only activate ``greeter`` once *both* the client is connected
# and the worker has been observed via the registry.
state = {"client_connected": False, "greeter_ready": False}
async def maybe_activate():
if not (state["client_connected"] and state["greeter_ready"]):
return
await worker.activate_worker(
"greeter",
args=LLMWorkerActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available "
"products and ask how you can help."
),
},
],
),
)
async def on_greeter_ready(_data: WorkerReadyData) -> None:
state["greeter_ready"] = True
await maybe_activate()
await runner.registry.watch("greeter", on_greeter_ready)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
state["client_connected"] = True
await maybe_activate()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await worker.cancel()
await runner.run(worker)
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
parser = argparse.ArgumentParser(description="Main transport worker (PGMQ bus)")
parser.add_argument(
"--database-url",
default=os.getenv("DATABASE_URL"),
help="PostgreSQL DSN (or set DATABASE_URL env var)",
)
parser.add_argument(
"--channel",
default=os.getenv("PGMQ_CHANNEL", "pipecat_acme"),
help="PGMQ channel prefix",
)
main(parser)

View File

@@ -0,0 +1,152 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""LLM worker — run on Machine B (or locally alongside ``main.py``).
A standalone process that runs one LLM worker (greeter or support)
attached to the same Redis-backed `WorkerBus` as the main worker.
Multiple instances can run on different machines.
Usage::
python llm.py greeter --redis-url redis://localhost:6379
python llm.py support --redis-url redis://localhost:6379
Requirements:
- OPENAI_API_KEY
"""
import argparse
import asyncio
import os
from dotenv import load_dotenv
from loguru import logger
from redis.asyncio import Redis
from pipecat.bus.network.redis import RedisBus
from pipecat.pipeline.runner import PipelineRunner
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.workers.llm import LLMWorker, LLMWorkerActivationArgs, tool
load_dotenv(override=True)
WORKER_CONFIG = {
"greeter": {
"system_instruction": (
"You are a friendly greeter for Acme Corp. The available products "
"are: the Acme Rocket Boots, the Acme Invisible Paint, and the Acme "
"Tornado Kit. Ask which one they'd like to learn more about. "
"When the user picks a product or asks a question about one, "
"immediately call the transfer_to_agent tool with agent 'support'. "
"Do not answer product questions yourself. If the user says goodbye, "
"call the end_conversation tool. Do not mention transferring — just do it "
"seamlessly. Keep responses brief — this is a voice conversation."
),
"watch": ["support"],
},
"support": {
"system_instruction": (
"You are a support agent for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up "
"to 60 mph), Acme Invisible Paint (makes anything invisible for "
"24 hours, $49 per can), and Acme Tornado Kit (portable tornado "
"generator, $199, batteries included). Answer the user's questions "
"about these products. If the user wants to browse other products "
"or start over, call the transfer_to_agent tool with agent "
"'greeter'. If the user says goodbye, call the end_conversation "
"tool. Do not mention transferring — just do it seamlessly. "
"Keep responses brief — this is a voice conversation."
),
"watch": ["greeter"],
},
}
class AcmeLLMTask(LLMWorker):
"""LLM worker for Acme Corp with transfer and end tools."""
def __init__(self, name: str, *, system_instruction: str, watch: list[str]):
"""Initialize the AcmeLLMTask.
Args:
name: Unique worker name (``"greeter"`` or ``"support"``).
system_instruction: System prompt for this LLM role.
watch: Sibling worker names this worker will watch via the
registry so it knows when they become available for
handoff.
"""
llm = OpenAILLMService(
name=f"{name}::OpenAILLMService",
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=system_instruction),
)
super().__init__(name, llm=llm, bridged=())
self._watch = watch
async def start(self) -> None:
"""Register watches for sibling workers once ready."""
await super().start()
for worker_name in self._watch:
await self.watch_worker(worker_name)
@tool(cancel_on_interruption=False)
async def transfer_to_agent(self, params: FunctionCallParams, agent: str, reason: str):
"""Transfer the user to another agent.
Args:
agent (str): The agent to transfer to (e.g. 'greeter', 'support').
reason (str): Why the user is being transferred.
"""
logger.info(f"Task '{self.name}': transferring to '{agent}' ({reason})")
await self.activate_worker(
agent,
args=LLMWorkerActivationArgs(messages=[{"role": "developer", "content": reason}]),
deactivate_self=True,
result_callback=params.result_callback,
)
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
async def main_async() -> None:
parser = argparse.ArgumentParser(description="LLM worker (greeter or support)")
parser.add_argument("worker", choices=list(WORKER_CONFIG), help="Which worker to run")
parser.add_argument("--redis-url", default="redis://localhost:6379", help="Redis URL")
parser.add_argument("--channel", default="pipecat:acme", help="Redis pub/sub channel")
args = parser.parse_args()
redis = Redis.from_url(args.redis_url)
bus = RedisBus(redis=redis, channel=args.channel)
config = WORKER_CONFIG[args.worker]
worker = AcmeLLMTask(
args.worker,
system_instruction=config["system_instruction"],
watch=config["watch"],
)
runner = PipelineRunner(bus=bus, handle_sigint=True)
logger.info(f"Starting {args.worker} worker, waiting for activation...")
await runner.run(worker)
if __name__ == "__main__":
asyncio.run(main_async())

View File

@@ -0,0 +1,169 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Main transport worker — run on Machine A.
Handles audio I/O (STT, TTS) and bridges frames to the bus. The LLM
workers run as separate processes (possibly on different
machines) and connect to the same Redis-backed `WorkerBus`.
Usage::
python main.py --redis-url redis://localhost:6379
Requirements:
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
"""
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from redis.asyncio import Redis
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor
from pipecat.bus.network.redis import RedisBus
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.registry.types import WorkerReadyData
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.llm import LLMWorkerActivationArgs
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
redis = Redis.from_url(runner_args.cli_args.redis_url)
bus = RedisBus(redis=redis, channel=runner_args.cli_args.channel)
runner = PipelineRunner(bus=bus, handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
bridge = BusBridgeProcessor(
bus=runner.bus,
worker_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
# The remote LLM workers may take a moment to register on the bus.
# We only activate ``greeter`` once *both* the client is connected
# and the worker has been observed via the registry.
state = {"client_connected": False, "greeter_ready": False}
async def maybe_activate():
if not (state["client_connected"] and state["greeter_ready"]):
return
await worker.activate_worker(
"greeter",
args=LLMWorkerActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available "
"products and ask how you can help."
),
},
],
),
)
async def on_greeter_ready(_data: WorkerReadyData) -> None:
state["greeter_ready"] = True
await maybe_activate()
await runner.registry.watch("greeter", on_greeter_ready)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
state["client_connected"] = True
await maybe_activate()
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await worker.cancel()
await runner.run(worker)
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
parser = argparse.ArgumentParser(description="Main transport worker (Redis bus)")
parser.add_argument("--redis-url", default="redis://localhost:6379", help="Redis URL")
parser.add_argument("--channel", default="pipecat:acme", help="Redis pub/sub channel")
main(parser)

View File

@@ -0,0 +1,22 @@
# Audio services
DEEPGRAM_API_KEY=
CARTESIA_API_KEY=
# LLM
OPENAI_API_KEY=
# Voice code assistant (Claude Agent SDK)
ANTHROPIC_API_KEY=
# Transport (optional — only needed with --transport daily)
DAILY_API_KEY=
# Distributed handoff via PGMQ
# Get from: Supabase dashboard > Project Settings > Database > Connection string
# The session-mode pooler (port 5432) is preferred. The transaction-mode
# pooler (port 6543) also works but logs benign "resetting connection"
# warnings.
# Format (session pooler):
# postgresql://postgres.<project-ref>:<password>@aws-0-<region>.pooler.supabase.com:5432/postgres
DATABASE_URL=
PGMQ_CHANNEL=pipecat_acme

View File

@@ -0,0 +1,270 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Two LLM workers with per-worker TTS voices.
Same shape as ``local-handoff-two-agents.py``, but each child worker
runs its own TTS with a distinct voice. The main worker has no TTS —
audio comes from the child workers via the bus and is played by the
main worker's transport. Tasks announce the transfer ("let me connect
you with...") before handing off.
Architecture::
Main worker (no TTS):
transport.in → STT → user_agg → BusBridge → transport.out → assistant_agg
Child worker (with TTS):
bridge_in → LLM → TTS → bridge_out
Requirements:
- OPENAI_API_KEY
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DAILY_API_KEY (for Daily transport)
"""
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.llm import LLMWorker, LLMWorkerActivationArgs, tool
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
class AcmeTTSTask(LLMWorker):
"""Child worker with its own LLM + TTS, bridged to the main worker.
Each child wraps the standard ``Pipeline([llm])`` with an extra
TTS processor so audio is produced locally by each child and
shipped to the main worker over the bus.
"""
def __init__(self, name: str, *, llm: OpenAILLMService, voice_id: str):
"""Initialize the child worker.
Args:
name: Unique worker name.
llm: The LLM service for this child.
voice_id: Cartesia voice id for this child's TTS.
"""
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(voice=voice_id),
)
super().__init__(
name,
llm=llm,
pipeline=Pipeline([llm, tts]),
bridged=(),
)
@tool(cancel_on_interruption=False)
async def transfer_to_agent(self, params: FunctionCallParams, agent: str, reason: str):
"""Transfer the user to another agent.
Args:
agent (str): The agent to transfer to (e.g. 'greeter', 'support').
reason (str): Why the user is being transferred.
"""
logger.info(f"Task '{self.name}': transferring to '{agent}' ({reason})")
await self.activate_worker(
agent,
messages=[
{
"role": "developer",
"content": f"Tell the user about the transfer ({reason}).",
}
],
args=LLMWorkerActivationArgs(
messages=[{"role": "developer", "content": reason}],
),
deactivate_self=True,
result_callback=params.result_callback,
)
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
def build_greeter() -> AcmeTTSTask:
"""Greeter: routes the user to support when they pick a product."""
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a friendly greeter for Acme Corp. The available products "
"are: the Acme Rocket Boots, the Acme Invisible Paint, and the Acme "
"Tornado Kit. Ask which one they'd like to learn more about. "
"When the user picks a product or asks a question about one, "
"call the transfer_to_agent tool with agent 'support'. "
"Do not answer product questions yourself. If the user says goodbye, "
"call the end_conversation tool. Keep responses brief — this is a "
"voice conversation."
),
),
)
return AcmeTTSTask(
"greeter",
llm=llm,
voice_id="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
)
def build_support() -> AcmeTTSTask:
"""Support: answers product questions, can hand back to the greeter."""
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a support agent for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up "
"to 60 mph), Acme Invisible Paint (makes anything invisible for "
"24 hours, $49 per can), and Acme Tornado Kit (portable tornado "
"generator, $199, batteries included). Answer the user's questions "
"about these products. If the user wants to browse other products "
"or start over, call the transfer_to_agent tool with agent "
"'greeter'. If the user says goodbye, call the end_conversation "
"tool. Keep responses brief — this is a voice conversation."
),
),
)
return AcmeTTSTask(
"support",
llm=llm,
voice_id="a167e0f3-df7e-4d52-a9c3-f949145efdab", # Blake
)
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info("Starting two-agents-with-tts bot")
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
# The main worker has no TTS. Audio comes from the children over
# the bus; the main bridge tees user context out and pushes
# incoming audio/text frames back into the local pipeline.
bridge = BusBridgeProcessor(
bus=runner.bus,
worker_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
await worker.activate_worker(
"greeter",
args=LLMWorkerActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available products "
"and ask how you can help."
),
},
],
),
)
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
await runner.add_worker(build_greeter())
await runner.add_worker(build_support())
await runner.add_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -0,0 +1,243 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Two LLM workers with a main worker bridging transport to the bus.
Demonstrates multi-worker coordination: a main worker handles transport I/O
(STT, TTS) and bridges frames to the bus. Two LLM workers — a greeter and
a support worker — each run their own LLM pipeline and hand off control
between each other.
The user talks to one worker at a time. Hand-offs are seamless — the LLM
decides when to transfer based on its tools.
Requirements:
- OPENAI_API_KEY
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DAILY_API_KEY (for Daily transport)
"""
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.llm import LLMWorker, LLMWorkerActivationArgs, tool
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
class AcmeLLMTask(LLMWorker):
"""LLM-only child worker with transfer/end tools.
Receives user context from the main worker via the bus, runs its LLM,
and ships generated text frames back. The main worker's TTS turns the
text into audio.
Passing ``bridged=()`` tells :class:`PipelineWorker` to wrap the LLM
pipeline with bus edge processors so frames flow between this worker
and the main worker automatically.
"""
@tool(cancel_on_interruption=False)
async def transfer_to_agent(self, params: FunctionCallParams, agent: str, reason: str):
"""Transfer the user to another agent.
Args:
agent (str): The agent to transfer to (e.g. 'greeter', 'support').
reason (str): Why the user is being transferred.
"""
logger.info(f"Task '{self.name}': transferring to '{agent}' ({reason})")
await self.activate_worker(
agent,
args=LLMWorkerActivationArgs(
messages=[{"role": "developer", "content": reason}],
),
deactivate_self=True,
result_callback=params.result_callback,
)
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
def build_greeter() -> AcmeLLMTask:
"""Greeter: routes the user to support when they pick a product."""
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a friendly greeter for Acme Corp. The available products "
"are: the Acme Rocket Boots, the Acme Invisible Paint, and the Acme "
"Tornado Kit. Ask which one they'd like to learn more about. "
"When the user picks a product or asks a question about one, "
"immediately call the transfer_to_agent tool with agent 'support'. "
"Do not answer product questions yourself. If the user says goodbye, "
"call the end_conversation tool. Do not mention transferring — just do it "
"seamlessly. Keep responses brief — this is a voice conversation."
),
),
)
return AcmeLLMTask("greeter", llm=llm, bridged=())
def build_support() -> AcmeLLMTask:
"""Support: answers product questions, can hand back to the greeter."""
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a support agent for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up "
"to 60 mph), Acme Invisible Paint (makes anything invisible for "
"24 hours, $49 per can), and Acme Tornado Kit (portable tornado "
"generator, $199, batteries included). Answer the user's questions "
"about these products. If the user wants to browse other products "
"or start over, call the transfer_to_agent tool with agent "
"'greeter'. If the user says goodbye, call the end_conversation "
"tool. Do not mention transferring — just do it seamlessly. "
"Keep responses brief — this is a voice conversation."
),
),
)
return AcmeLLMTask("support", llm=llm, bridged=())
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info("Starting two-agent bot")
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
# The main bridge sends user-side context downstream to the children
# via the bus, and the children's generated text comes back here so
# the TTS can speak it.
bridge = BusBridgeProcessor(
bus=runner.bus,
worker_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
await worker.activate_worker(
"greeter",
args=LLMWorkerActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available products "
"and ask how you can help."
),
},
],
),
)
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
await runner.add_worker(build_greeter())
await runner.add_worker(build_support())
await runner.add_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -0,0 +1,236 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Parallel debate using job groups.
A voice bot receives a topic from the user and fans out to three
workers in parallel via ``worker.job_group(...)``. Each worker
runs its own LLM context, so it remembers previous topics across
debate rounds. The bot collects all three perspectives and the
main-worker LLM synthesizes a balanced answer.
Architecture::
Main worker (transport + LLM + ``debate`` tool)
└── job_group(advocate, critic, analyst)
└── DebateWorker (LLMContextWorker, one per role)
Requirements:
- OPENAI_API_KEY
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DAILY_API_KEY (for Daily transport)
"""
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusJobRequestMessage
from pipecat.frames.frames import LLMMessagesAppendFrame, LLMRunFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
AssistantTurnStoppedMessage,
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.llm import LLMContextWorker
load_dotenv(override=True)
ROLE_PROMPTS = {
"advocate": (
"You argue IN FAVOR of the topic. Present the strongest case for why "
"this is a good idea, with concrete benefits. Be persuasive but honest. "
"Be concise, just 2-3 sentences."
),
"critic": (
"You argue AGAINST the topic. Present the strongest concerns, risks, "
"and downsides. Be critical but fair. Be concise, just 2-3 sentences."
),
"analyst": (
"You provide a BALANCED, NEUTRAL analysis. Weigh both sides objectively "
"and highlight the key trade-offs. Be concise, just 2-3 sentences."
),
}
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
class DebateWorker(LLMContextWorker):
"""Worker that generates a perspective using its own LLM context.
Each worker keeps its own ``LLMContext`` so it remembers previous
topics across multiple debate rounds. Job requests append the new
topic and trigger the LLM; the assistant-aggregator captures the
full reply and sends it back as the job response.
"""
def __init__(self, role: str):
"""Initialize the DebateWorker.
Args:
role: One of ``"advocate"``, ``"critic"``, ``"analyst"`` —
used as the worker name and selects the system prompt.
"""
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(system_instruction=ROLE_PROMPTS[role]),
)
super().__init__(role, llm=llm)
self._role = role
self._current_job_id: str | None = None
@self.assistant_aggregator.event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(aggregator, message: AssistantTurnStoppedMessage):
text = message.content
logger.info(f"Worker '{self.name}': completed ({len(text)} chars)")
if self._current_job_id:
job_id = self._current_job_id
self._current_job_id = None
await self.send_job_response(job_id, {"role": self._role, "text": text})
async def on_job_request(self, message: BusJobRequestMessage) -> None:
"""Inject the topic and run the LLM."""
await super().on_job_request(message)
self._current_job_id = message.job_id
await self.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "developer", "content": f"Topic: {message.payload['topic']}"}],
run_llm=True,
)
)
async def debate(params: FunctionCallParams, topic: str):
"""Analyze a topic from multiple perspectives (advocate, critic, analyst).
Args:
topic (str): The topic or question to debate.
"""
logger.info(f"Starting debate on '{topic}'")
async with params.pipeline_worker.job_group(
*ROLE_PROMPTS, payload={"topic": topic}, timeout=30
) as tg:
pass
result = "\n\n".join(f"{r['role'].upper()}: {r['text']}" for r in tg.responses.values())
logger.info("Debate complete, synthesizing")
await params.result_callback(result)
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info("Starting parallel-debate bot")
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a debate moderator in a voice conversation. When the user "
"gives you a topic, call the debate tool to gather perspectives from "
"three viewpoints (advocate, critic, analyst). Then synthesize the "
"results into a clear, balanced summary for the user. Keep your "
"responses concise and natural for speaking."
),
),
)
llm.register_direct_function(debate, cancel_on_interruption=False, timeout_secs=60)
context = LLMContext(tools=ToolsSchema(standard_tools=[debate]))
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
llm,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
context.add_message(
{
"role": "developer",
"content": (
"Greet the user and tell them you can moderate a debate on any "
"topic. Ask what they'd like to explore."
),
}
)
await worker.queue_frame(LLMRunFrame())
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
for role in ROLE_PROMPTS:
await runner.add_worker(DebateWorker(role))
await runner.add_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -0,0 +1,120 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Remote assistant LLM server.
Runs a FastAPI server that accepts WebSocket connections from a
``main.py``-style client. Each connection spins up a
`WebSocketProxyServerTask` bridging the socket to a local
`PipelineRunner` and an `LLMWorker` that handles the conversation.
Usage::
python assistant.py
python assistant.py --port 9000
Requirements:
- OPENAI_API_KEY
"""
import argparse
import os
import uvicorn
from dotenv import load_dotenv
from fastapi import FastAPI, WebSocket
from loguru import logger
from pipecat.bus import BusFrameMessage
from pipecat.pipeline.runner import PipelineRunner
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.workers.llm import LLMWorker, tool
from pipecat.workers.proxy.websocket import WebSocketProxyServerTask
load_dotenv(override=True)
app = FastAPI()
class AcmeAssistant(LLMWorker):
"""Handles greetings, product questions, and conversation end."""
def __init__(self):
"""Initialize the AcmeAssistant LLM worker."""
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a friendly assistant for Acme Corp. You know about three "
"products: Acme Rocket Boots (jet-powered boots, $299, run up to "
"60 mph), Acme Invisible Paint (makes anything invisible for 24 hours, "
"$49 per can), and Acme Tornado Kit (portable tornado generator, $199, "
"batteries included). Greet the user, help them with product questions, "
"and call end_conversation when the user says goodbye. "
"Keep responses brief, this is a voice conversation."
),
),
)
super().__init__("assistant", llm=llm, bridged=())
@tool
async def end_conversation(self, params: FunctionCallParams, reason: str):
"""End the conversation when the user says goodbye.
Args:
reason (str): Why the conversation is ending.
"""
logger.info(f"Task '{self.name}': ending conversation ({reason})")
await self.end(
reason=reason,
messages=[{"role": "developer", "content": reason}],
result_callback=params.result_callback,
)
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""Handle a WebSocket connection from the main bot's proxy."""
await websocket.accept()
runner = PipelineRunner(handle_sigint=False)
proxy = WebSocketProxyServerTask(
"gateway",
websocket=websocket,
worker_name="assistant",
remote_worker_name="acme",
forward_messages=(BusFrameMessage,),
)
@proxy.event_handler("on_client_connected")
async def on_client_connected(proxy, client):
logger.info("WebSocket client connected")
@proxy.event_handler("on_client_disconnected")
async def on_client_disconnected(proxy, client):
logger.info("WebSocket client disconnected")
await runner.cancel()
assistant = AcmeAssistant()
await runner.add_worker(proxy)
await runner.add_worker(assistant)
logger.info("Assistant server ready, waiting for activation")
await runner.run()
logger.info("Assistant server session ended")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Remote assistant LLM server")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
parser.add_argument("--port", type=int, default=8765, help="Port to listen on")
args = parser.parse_args()
uvicorn.run(app, host=args.host, port=args.port)

View File

@@ -0,0 +1,171 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Main transport worker with a WebSocket proxy to a remote LLM server.
Handles audio I/O (STT, TTS) and bridges frames to the bus. A
`WebSocketProxyClientTask` forwards bus messages to a remote LLM
server (see ``assistant.py``) over WebSocket.
Usage::
python main.py --remote-url ws://localhost:8765/ws
Requirements:
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
"""
import argparse
import os
from dotenv import load_dotenv
from loguru import logger
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusBridgeProcessor, BusFrameMessage
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.registry.types import WorkerReadyData
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
from pipecat.workers.llm import LLMWorkerActivationArgs
from pipecat.workers.proxy.websocket import WebSocketProxyClientTask
load_dotenv(override=True)
MAIN_NAME = "acme"
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
context = LLMContext()
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
bridge = BusBridgeProcessor(
bus=runner.bus,
worker_name=MAIN_NAME,
name=f"{MAIN_NAME}::BusBridge",
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
bridge,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
name=MAIN_NAME,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
# Forward bus frame messages over the WebSocket so the remote
# assistant sees user-side context and can ship back its replies.
proxy = WebSocketProxyClientTask(
"proxy",
url=runner_args.cli_args.remote_url,
local_worker_name=MAIN_NAME,
remote_worker_name="assistant",
forward_messages=(BusFrameMessage,),
)
async def on_assistant_ready(_data: WorkerReadyData) -> None:
logger.info("Remote assistant ready, activating")
await worker.activate_worker(
"assistant",
args=LLMWorkerActivationArgs(
messages=[
{
"role": "developer",
"content": (
"Welcome the user to Acme Corp, mention the available "
"products and ask how you can help."
),
},
],
),
)
await runner.registry.watch("assistant", on_assistant_ready)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected, activating proxy")
await worker.activate_worker("proxy")
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
await runner.add_worker(proxy)
await runner.add_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
parser = argparse.ArgumentParser(description="Main transport worker with WebSocket proxy")
parser.add_argument(
"--remote-url",
default="ws://localhost:8765/ws",
help="WebSocket URL of the remote LLM server",
)
main(parser)

View File

@@ -0,0 +1,334 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Voice agent + sensor-controller worker, both as plain PipelineTasks.
Two ``PipelineWorker`` instances run side by side:
- The **voice agent** is built inline in ``run_bot`` — a standard
transport + STT + LLM + TTS pipeline. Its LLM has a single tool,
``ask_controller(question)``, which forwards the user's request to
the controller over the bus and speaks back the response.
- The **sensor controller** (``build_sensor_controller``) is a
``PipelineWorker`` whose pipeline runs a simulated temperature sensor
(see ``sensor.py``) alongside its own LLM. The worker LLM has tool
access to read the current reading, inspect rolling stats, and
mutate the simulated sensor's target temperature and response rate.
The worker does **not** subclass ``LLMWorker`` and is **not** bridged.
The voice agent and the controller communicate exclusively through
``BusJobRequestMessage`` / ``BusJobResponseMessage``. The controller
collects responses by listening to the assistant aggregator's
``on_assistant_turn_stopped`` event and pairing each LLM completion
with the in-flight job id.
Requirements:
- OPENAI_API_KEY
- DEEPGRAM_API_KEY
- CARTESIA_API_KEY
- DAILY_API_KEY (for Daily transport)
Example voice exchange::
User: What's the temperature?
Controller: 22.1°C, holding steady.
User: Make it warmer.
Controller: I set the target to 26°C. Give it about 20 seconds.
User: Is it stable yet?
Controller: It's at 25.4°C and still climbing — almost there.
User: Why is it slow?
Controller: The response rate is 5%. I sped it up to 20%; it'll settle faster now.
"""
import os
from dotenv import load_dotenv
from loguru import logger
from sensor import SensorReader, SensorStats
from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.audio.vad.silero import SileroVADAnalyzer
from pipecat.bus import BusJobRequestMessage
from pipecat.frames.frames import LLMMessagesAppendFrame, LLMRunFrame
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.runner import PipelineRunner
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.aggregators.llm_response_universal import (
AssistantTurnStoppedMessage,
LLMContextAggregatorPair,
LLMUserAggregatorParams,
)
from pipecat.runner.types import RunnerArguments
from pipecat.runner.utils import create_transport
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.services.deepgram.stt import DeepgramSTTService
from pipecat.services.llm_service import FunctionCallParams
from pipecat.services.openai.llm import OpenAILLMService
from pipecat.transports.base_transport import BaseTransport, TransportParams
from pipecat.transports.daily.transport import DailyParams
load_dotenv(override=True)
transport_params = {
"daily": lambda: DailyParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
"webrtc": lambda: TransportParams(
audio_in_enabled=True,
audio_out_enabled=True,
),
}
def build_sensor_controller() -> PipelineWorker:
"""Build the controller worker as a plain :class:`PipelineWorker`.
The pipeline shape is::
SensorReader -> SensorStats -> user_agg -> llm -> assistant_agg
``SensorReader`` runs an autonomous tick loop that emits a
:class:`SensorReadingFrame` every second; ``SensorStats`` consumes
those readings and exposes rolling statistics. The LLM has four
direct tools that read or mutate the sensor.
Jobs arrive via the ``on_job_request`` event handler. The handler
stores the active ``job_id``, then queues an
:class:`LLMMessagesAppendFrame` with the user's question and runs
the LLM. When the assistant turn finishes (signalled by the
assistant aggregator's ``on_assistant_turn_stopped`` event), the
handler sends a :class:`BusJobResponseMessage` carrying the LLM's
answer back to the voice agent.
"""
sensor = SensorReader()
stats = SensorStats()
async def get_current_reading(params: FunctionCallParams):
"""Read the sensor's current temperature in degrees Celsius."""
await params.result_callback({"temperature": round(sensor.current, 2)})
async def get_stats(params: FunctionCallParams):
"""Rolling minimum, maximum, average, and trend of the temperature."""
await params.result_callback(
{
"min": round(stats.min, 2),
"max": round(stats.max, 2),
"avg": round(stats.avg, 2),
"trend": stats.trend,
}
)
async def set_target_temperature(params: FunctionCallParams, target_celsius: float):
"""Adjust the target temperature; the sensor will drift toward it.
Args:
target_celsius (float): The new target temperature in degrees Celsius.
"""
sensor.set_target(target_celsius)
await params.result_callback({"ok": True, "new_target": target_celsius})
async def set_response_rate(params: FunctionCallParams, rate: float):
"""Set how aggressively the sensor approaches the target.
Args:
rate (float): Response rate between 0.01 (slow) and 0.3 (fast).
"""
sensor.set_response_rate(rate)
await params.result_callback({"ok": True, "new_rate": rate})
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a temperature sensor controller. You manage a single "
"thermometer and answer the user's questions about it. Use the "
"provided tools to read the current temperature, inspect rolling "
"statistics, change the target temperature, or change how fast "
"the sensor responds. When the user asks for a vague change "
"('make it warmer', 'cooler'), pick a sensible target and call "
"set_target_temperature. Always answer in one or two short "
"sentences — your reply is spoken aloud."
),
),
)
llm.register_direct_function(get_current_reading)
llm.register_direct_function(get_stats)
llm.register_direct_function(set_target_temperature)
llm.register_direct_function(set_response_rate)
context = LLMContext(
tools=ToolsSchema(
standard_tools=[
get_current_reading,
get_stats,
set_target_temperature,
set_response_rate,
]
)
)
aggregators = LLMContextAggregatorPair(context)
pipeline = Pipeline(
[
sensor,
stats,
aggregators.user(),
llm,
aggregators.assistant(),
]
)
worker = PipelineWorker(pipeline, name="controller")
# The controller handles one job at a time (the LLM pipeline can only
# run one turn at a time). ``state["job_id"]`` pairs the in-flight
# job with the next ``on_assistant_turn_stopped`` event.
state: dict[str, str | None] = {"job_id": None}
@worker.event_handler("on_job_request")
async def on_request(_task, message: BusJobRequestMessage):
question = message.payload["question"]
logger.info(f"Controller: received question '{question}'")
state["job_id"] = message.job_id
await worker.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "user", "content": question}],
run_llm=True,
)
)
@aggregators.assistant().event_handler("on_assistant_turn_stopped")
async def on_assistant_turn_stopped(_aggregator, message: AssistantTurnStoppedMessage):
# The aggregator fires this event on every ``LLMFullResponseEndFrame``,
# including the tool-call round that precedes the tool result and has
# no spoken text. Skip those so we only forward the LLM's final
# response to the voice agent.
if not message.content:
return
if state["job_id"] is None:
return
job_id, state["job_id"] = state["job_id"], None
logger.info(f"Controller: answering job {job_id[:8]}")
await worker.send_job_response(job_id, response={"answer": message.content})
return worker
async def run_bot(transport: BaseTransport, runner_args: RunnerArguments):
logger.info("Starting sensor-controller bot")
# Voice agent: standard transport + STT + LLM + TTS pipeline. The
# only tool the voice LLM has is ``ask_controller`` — it does not
# know anything about temperatures, trends, or response rates.
stt = DeepgramSTTService(api_key=os.environ["DEEPGRAM_API_KEY"])
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="9626c31c-bec5-4cca-baa8-f8ba9e84c8bc", # Jacqueline
),
)
async def ask_controller(params: FunctionCallParams, question: str):
"""Ask the temperature sensor controller anything about the sensor.
Forward the user's request verbatim and speak back the answer.
Args:
question (str): The user's question or instruction to forward to the controller.
"""
logger.info(f"Voice agent: forwarding to controller: '{question}'")
async with params.pipeline_worker.job(
"controller", payload={"question": question}, timeout=30
) as t:
pass
await params.result_callback(t.response["answer"])
llm = OpenAILLMService(
api_key=os.environ["OPENAI_API_KEY"],
settings=OpenAILLMService.Settings(
system_instruction=(
"You are a friendly voice assistant with access to a temperature "
"sensor controller. For ANY request about the temperature — "
"reading it, adjusting it, checking trends, changing how fast it "
"responds — call the ask_controller tool. Forward the user's "
"request verbatim. Then speak the controller's answer back. "
"Keep responses brief; do not add extra commentary."
),
),
)
llm.register_direct_function(ask_controller, timeout_secs=60)
context = LLMContext(tools=ToolsSchema(standard_tools=[ask_controller]))
aggregators = LLMContextAggregatorPair(
context,
user_params=LLMUserAggregatorParams(vad_analyzer=SileroVADAnalyzer()),
)
pipeline = Pipeline(
[
transport.input(),
stt,
aggregators.user(),
llm,
tts,
transport.output(),
aggregators.assistant(),
]
)
worker = PipelineWorker(
pipeline,
params=PipelineParams(
enable_metrics=True,
enable_usage_metrics=True,
),
idle_timeout_secs=runner_args.pipeline_idle_timeout_secs,
)
runner = PipelineRunner(handle_sigint=runner_args.handle_sigint)
@transport.event_handler("on_client_connected")
async def on_client_connected(transport, client):
logger.info("Client connected")
context.add_message(
{
"role": "developer",
"content": (
"Greet the user and let them know you can read or adjust a "
"temperature sensor on their behalf."
),
}
)
await worker.queue_frame(LLMRunFrame())
@transport.event_handler("on_client_disconnected")
async def on_client_disconnected(transport, client):
logger.info("Client disconnected")
await runner.cancel()
await runner.add_worker(build_sensor_controller())
await runner.add_worker(worker)
await runner.run()
async def bot(runner_args: RunnerArguments):
"""Main bot entry point compatible with Pipecat Cloud."""
transport = await create_transport(runner_args, transport_params)
await run_bot(transport, runner_args)
if __name__ == "__main__":
from pipecat.runner.run import main
main()

View File

@@ -0,0 +1,186 @@
#
# Copyright (c) 2026, Daily
#
# SPDX-License-Identifier: BSD 2-Clause License
#
"""Temperature sensor processors for the sensor-controller example.
Two custom :class:`FrameProcessor` subclasses that give the worker
pipeline real autonomous frame flow:
- :class:`SensorReader` simulates a thermometer. It runs an async tick
loop that advances ``current`` toward ``target`` with a first-order
lag plus Gaussian noise, and pushes a :class:`SensorReadingFrame` on
every tick. ``target`` and ``response_rate`` are mutable so the
worker's LLM can adjust them via tool calls.
- :class:`SensorStats` consumes the readings, maintains a rolling
window, and exposes ``current`` / ``min`` / ``max`` / ``avg`` /
``trend`` as properties. The worker LLM reads these directly when
answering the user.
"""
import random
import time
from collections import deque
from dataclasses import dataclass
from pipecat.frames.frames import DataFrame, Frame, StartFrame
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
@dataclass
class SensorReadingFrame(DataFrame):
"""A single temperature reading emitted by :class:`SensorReader`.
Parameters:
temperature: The reading in degrees Celsius.
timestamp: Unix timestamp when the reading was taken.
"""
temperature: float = 0.0
timestamp: float = 0.0
class SensorReader(FrameProcessor):
"""Simulated temperature sensor with adjustable target and response rate.
Each tick, ``current`` is updated as::
current += (target - current) * response_rate + gauss(0, noise_sigma)
This is a first-order lag toward ``target``. With ``response_rate=0.05``
and a 1s tick, the current reading reaches ~halfway to target in ~14s;
with ``response_rate=0.2`` it converges in ~510s.
"""
def __init__(
self,
*,
start_temp: float = 22.0,
sample_period_s: float = 1.0,
response_rate: float = 0.05,
noise_sigma: float = 0.1,
):
"""Initialize the sensor.
Args:
start_temp: Initial temperature and initial target (°C).
sample_period_s: Seconds between successive readings.
response_rate: Fraction of the gap toward target closed each tick
(clamped to ``[0.0, 1.0]``).
noise_sigma: Standard deviation of the Gaussian noise added to
each reading.
"""
super().__init__()
self._current = start_temp
self._target = start_temp
self._response_rate = max(0.0, min(1.0, response_rate))
self._noise_sigma = noise_sigma
self._sample_period_s = sample_period_s
self._tick_task = None
@property
def current(self) -> float:
"""The most recent temperature reading (°C)."""
return self._current
@property
def target(self) -> float:
"""The temperature the sensor is drifting toward (°C)."""
return self._target
@property
def response_rate(self) -> float:
"""Fraction of the target-current gap closed per tick."""
return self._response_rate
def set_target(self, value: float) -> None:
"""Set a new target temperature (°C)."""
self._target = value
def set_response_rate(self, rate: float) -> None:
"""Set how aggressively the sensor approaches the target.
Args:
rate: Fraction in ``[0.0, 1.0]``. Clamped to that range.
"""
self._response_rate = max(0.0, min(1.0, rate))
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, StartFrame) and self._tick_task is None:
self._tick_task = self.create_task(self._tick_loop(), "ticker")
await self.push_frame(frame, direction)
async def cleanup(self) -> None:
if self._tick_task is not None:
await self.cancel_task(self._tick_task)
self._tick_task = None
await super().cleanup()
async def _tick_loop(self) -> None:
import asyncio
while True:
await asyncio.sleep(self._sample_period_s)
gap = self._target - self._current
self._current += gap * self._response_rate + random.gauss(0, self._noise_sigma)
await self.push_frame(
SensorReadingFrame(temperature=self._current, timestamp=time.time()),
FrameDirection.DOWNSTREAM,
)
class SensorStats(FrameProcessor):
"""Rolling-window statistics over :class:`SensorReadingFrame`s.
Consumes readings as they flow downstream and exposes rolling
``min`` / ``max`` / ``avg`` / ``trend`` as properties — the worker
LLM reads them directly when responding to the user.
"""
def __init__(self, window: int = 30):
"""Initialize the stats aggregator.
Args:
window: Number of recent readings to retain.
"""
super().__init__()
self._readings: deque[float] = deque(maxlen=window)
@property
def current(self) -> float:
"""The most recent reading, or 0.0 if none have been seen."""
return self._readings[-1] if self._readings else 0.0
@property
def min(self) -> float:
return min(self._readings) if self._readings else 0.0
@property
def max(self) -> float:
return max(self._readings) if self._readings else 0.0
@property
def avg(self) -> float:
return sum(self._readings) / len(self._readings) if self._readings else 0.0
@property
def trend(self) -> str:
"""``"rising"`` / ``"falling"`` / ``"stable"`` based on first vs. last half of the window."""
if len(self._readings) < 4:
return "stable"
half = len(self._readings) // 2
old_avg = sum(list(self._readings)[:half]) / half
new_avg = sum(list(self._readings)[half:]) / (len(self._readings) - half)
diff = new_avg - old_avg
if abs(diff) < 0.25:
return "stable"
return "rising" if diff > 0 else "falling"
async def process_frame(self, frame: Frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, SensorReadingFrame):
self._readings.append(frame.temperature)
await self.push_frame(frame, direction)