Pipecat Avatar: Add a Face to Your Voice Agent
Use pipecat-facemode to add a lip-synced avatar to a Pipecat pipeline: one FrameProcessor between TTS and output transport. Setup, tokens, pitfalls.
Use the FaceMode Pipecat avatar integration to turn your pipeline's TTS audio into a lip-synced avatar published into a LiveKit room you own. pipecat-facemode is a single FrameProcessor that sits between your TTS service and your output transport - your STT, LLM, TTS, and turn detection stay exactly where they are.
The examples below are adapted from the pipecat-facemode README and the Pipecat integration docs. They assume a Pipecat pipeline that already runs against Pipecat; they are not a standalone bot.
What you need
Five things, all server-side:
- A FaceMode account. Sign-up is waitlist-gated during the private beta at console.facemode.io/signup. Approved testers get 3,600 free credits - 60 minutes of avatar video, no card required.
- A FaceMode API key (
fm_...) from the dashboard. - An avatar ID for the face that should speak. Pick from the library across five art styles, or upload one front-facing portrait.
- A LiveKit room you own, plus two server-side tokens - a publish token for the FaceMode worker and a subscribe token for your Pipecat process. Step 2 covers the grants.
- Python 3.13+, Pipecat 1.7 or later,
websockets14-16. The processor tapsTTSAudioRawFrame, so your pipeline needs a TTS service upstream - the examples use Cartesia, but anything that emits TTS frames works.
The 30-second mental model
FaceMode is not a Pipecat transport and does not replace one. It is a frame processor in the middle of your pipeline:
transport input -> STT -> LLM -> TTS -> FaceModeVideoService -> transport outputTwo separate LiveKit connections are involved:
- The FaceMode worker joins your room as its own participant (
facemode-avatarby default) and publishes the avatar's audio and video tracks. - The Pipecat service subscribes to the same room with a second token and converts the avatar's tracks back into
OutputAudioRawFrameand output image frames for whatever transport follows it.
When your pipeline's own transport is that same LiveKit room, the avatar tracks are already in it - so the example disables the transport's audio and video outputs rather than publishing them twice.
One scope note worth stating plainly: the package's room object is LiveKit-only today (LiveKitRoom validates type == "livekit"), with Daily support planned. The underlying FaceMode session API already accepts Daily rooms; until the plugin ships it, a Daily target on Pipecat means the direct API path.
Step 1: install
pip install pipecat-facemode
# the example also uses Pipecat's LiveKit transport and a Cartesia TTS
pip install "pipecat-ai[livekit,cartesia]"The package supports Pipecat 1.7 through the current 1.x line, LiveKit RTC 1.x, aiohttp 3.x, and NumPy 2.x.
Step 2: mint two LiveKit tokens on your server
Keep every token in this guide on the server. The worker token must grant room_join for the exact room name, can_publish, and can_subscribe so the avatar can join and publish its tracks. The subscriber token needs room_join and can_subscribe; grant it publish only if your application needs it. This excerpt is from the README's token section:
from livekit import api
worker_token = (
api.AccessToken()
.with_identity("facemode-avatar")
.with_name("FaceMode Avatar")
.with_grants(
api.VideoGrants(
room_join=True,
room=room_name,
can_publish=True,
can_subscribe=True,
)
)
.to_jwt()
)
subscriber_token = (
api.AccessToken()
.with_identity("pipecat-facemode-subscriber")
.with_name("Pipecat FaceMode Subscriber")
.with_grants(
api.VideoGrants(
room_join=True,
room=room_name,
can_subscribe=True,
)
)
.to_jwt()
)Do not share a participant identity between the two connections: a LiveKit room permits only one participant per identity, so the second join would displace the first. A single token with publish and subscribe grants can technically serve both connections, but separate identities are the production-safe default.
Step 3: wire the pipeline
This is the README's examples/basic_pipeline.py trimmed to the parts that matter - a LiveKit transport for user audio, a Cartesia TTS, the FaceMode processor, and a worker with a bumped start timeout. It is an excerpt, not a runnable script:
import os
from pipecat.pipeline.pipeline import Pipeline
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
from pipecat.services.cartesia.tts import CartesiaTTSService
from pipecat.transports.livekit.transport import LiveKitParams, LiveKitTransport
from pipecat.frames.frames import EndFrame, TTSSpeakFrame
from pipecat_facemode import FaceModeVideoService
transport = LiveKitTransport(
url=os.environ["LIVEKIT_URL"],
token=os.environ["LIVEKIT_TRANSPORT_TOKEN"],
room_name=os.environ["LIVEKIT_ROOM_NAME"],
params=LiveKitParams(
audio_in_enabled=True,
audio_out_enabled=False, # FaceMode publishes avatar audio into the room
video_out_enabled=False, # and video - republishing would duplicate it
video_out_is_live=True,
),
)
tts = CartesiaTTSService(
api_key=os.environ["CARTESIA_API_KEY"],
settings=CartesiaTTSService.Settings(
voice="71a7ad14-091c-4e8e-a314-022ece01c121",
),
)
facemode = FaceModeVideoService(
api_key=os.environ["FACEMODE_API_KEY"],
avatar_id=os.environ.get("FACEMODE_AVATAR_ID", ""),
room={
"type": "livekit",
"url": os.environ["LIVEKIT_URL"],
"token": os.environ["LIVEKIT_WORKER_TOKEN"],
},
livekit_subscriber_token=os.environ["LIVEKIT_SUBSCRIBER_TOKEN"],
avatar_participant_identity="facemode-avatar",
input_provider=os.environ.get("FACEMODE_INPUT_PROVIDER") or None,
room_name=os.environ["LIVEKIT_ROOM_NAME"],
)
pipeline = Pipeline([
transport.input(),
tts,
facemode,
transport.output(),
])
worker = PipelineWorker(
pipeline,
params=PipelineParams(audio_out_sample_rate=24_000),
start_timeout_secs=300,
)
@transport.event_handler("on_first_participant_joined")
async def on_first_participant_joined(_transport, _participant_id):
await worker.queue_frames(
[TTSSpeakFrame("Hello from a Pipecat pipeline connected to FaceMode.")]
)
@transport.event_handler("on_participant_disconnected")
async def on_participant_disconnected(_transport, _participant_id):
await worker.queue_frame(EndFrame())Three details worth noticing:
audio_out_enabled=False,video_out_enabled=False. The FaceMode worker already publishes both tracks into the same room. Leaving the transport's outputs on would duplicate the avatar's media.avatar_participant_identity. It filters which remote participant's tracks the service converts, and defaults tofacemode-avatar. Set it to whatever identity your worker token uses - a mismatch means the avatar's tracks are silently ignored.start_timeout_secs=300. Session creation waits on a remote avatar worker whose cold start can exceed Pipecat's default 20-second start watchdog. The README's own example raises it for that reason; skipping it is the most common way to see a first session die before it begins.
input_provider is optional: pin it (deepgram, gemini, gnani, elevenlabs, openai, cartesia, sarvam, or custom) when you want the backend to record which voice stack drives the session. Any TTS that streams audio can drive the face.
What the processor handles for you
The reason to use the plugin instead of the raw protocol is the session lifecycle it absorbs:
- Session and socket. On
StartFrameit connects to the room, callsPOST /api/sessions, polls readiness for up to 240 seconds if the worker is still starting, opens the ingestion WebSocket with a one-timewsToken, and negotiatesstart/startedbefore any audio flows. - Audio lifecycle. Each
TTSAudioRawFramegoes out as binary little-endian 16-bit PCM - no JSON or base64 wrapper - bracketed bystart_utteranceandend_utterance(the latter on a TTS stop frame or afterutterance_idle_timeout, default 0.35s, of silence). A Pipecat interruption frame sendscancel_utterance; Pipecat 1.7'sInterruptionFrameand releases with separateStart/StopInterruptionFrameclasses are both handled. - Frame ownership. Source TTS frames are consumed by default so your TTS audio and the avatar's returned audio are not both played. Set
forward_tts_audio=Trueonly when the pipeline deliberately needs both streams. - Reconnect. If the socket drops after startup, the service fetches a fresh one-time credential via
POST /api/sessions/{id}/reconnect, renegotiates, and resumes - at most 2 attempts with bounded backoff, and already-sent audio is never replayed. Intentional shutdowns never trigger reconnect. - Readiness.
await service.wait_for_avatar()resolves on FaceMode'saudio_readyevent or the first avatar video frame - call it before announcing to the user that avatar media is live. - Shutdown.
EndFrametriggers a graceful close:end_session, then up to 3 seconds waiting for the canonicalended. That acknowledgement confirms the protocol message, not that every backend cleanup step has finished.
Pitfalls checklist
Before your first session, check the things that bite most often:
- Identity collision. Worker and subscriber tokens must carry different participant identities - LiveKit allows one participant per identity per room.
- Double audio. No second audio output upstream of the service. Consumed
TTSAudioRawFrames are the guard, not a suggestion. - Duplicate room media.
audio_out_enabled=Falseandvideo_out_enabled=Falseon the pipeline's own LiveKit transport when it shares the avatar's room. - Startup watchdog. Bump
start_timeout_secspast the avatar worker's cold start, or first sessions die at 20 seconds. - Identity filter mismatch.
avatar_participant_identitymust equal the identity inside the worker token, or the avatar's tracks never reach your output frames. - Format lock. The negotiated sample rate must be an integer between 8,000 and 48,000 Hz with 1-2 channels; a mid-session format change raises
FaceModeProtocolError.PipelineParams(audio_out_sample_rate=24_000)matches common TTS output. websockets>=14is a hard requirement. The handshake uses theadditional_headersAPI with no fallback to an unaffinitized connection.- Beta limits. Sessions auto-end after 10 minutes, one concurrent session per account, and a session needs at least 60 credits of remaining balance to start.
What the latency target does not cover
FaceMode publishes an approximately 960ms audio-to-rendered-frame target for the avatar layer. Your user's perceived latency also includes turn detection, STT, LLM, TTS, transport, and client playback - none of which that figure measures. When you evaluate, time the same utterance across the same boundary (for example, TTS frame out to first avatar frame in the room), and keep session startup separate from steady-state response latency.
Plugin or direct API?
Use pipecat-facemode when your stack is Pipecat and your room is LiveKit - the frame processor inherits the pipeline's interruption and lifecycle semantics for free. Daily room support is planned for the plugin; until it ships, use the direct API when you need a Daily room, a non-Python service, or a stack with no Pipecat at all: create a session over REST, open a WebSocket, stream PCM. If your agent runs on LiveKit Agents rather than Pipecat, the sibling walkthrough is LiveKit Avatar Plugin: Add a Face to Your Voice Agent.
Pipecat avatar: short version
facemode = FaceModeVideoService(
api_key=os.environ["FACEMODE_API_KEY"],
avatar_id=os.environ["FACEMODE_AVATAR_ID"],
room={
"type": "livekit",
"url": os.environ["LIVEKIT_URL"],
"token": os.environ["LIVEKIT_WORKER_TOKEN"],
},
livekit_subscriber_token=os.environ["LIVEKIT_SUBSCRIBER_TOKEN"],
room_name=os.environ["LIVEKIT_ROOM_NAME"],
)
pipeline = Pipeline([transport.input(), tts, facemode, transport.output()])The whole integration is one processor between your TTS and your transport. For how this pattern compares to the rest of the category, see Interactive Avatar APIs Compared.
Ready to see your pipeline talk? Join the beta - 60 free minutes, no card - or read the Pipecat integration docs first.
[ Try it yourself ]
Give your voice agent a face.
Beta testers get 60 free minutes of avatar video. Keep your voice stack, keep your room - just add the face.