A chatbot can take two seconds to answer and nobody minds. A voice agent that takes two seconds feels broken: the caller says “hello?” again, talks over the reply, and the whole turn falls apart. Voice is a latency and turn-taking problem first, and a prompt-writing problem second.
This is the hands-on build of the STT → LLM → TTS route. I use LiveKit Agents (livekit-agents 1.8.7, released October 14, 2026) for the worked example, cover VAD, turn detection, barge-in, tool calls mid-call and phone hand-off, and compare it with Pipecat 1.12.0 using only facts I verified in the official docs. I did not run the agent or measure latency: every number here carries its vendor source and date.
1. The Problem: Voice Is a Pipeline With a Clock
A voice turn crosses at least five stages: the network, voice activity detection, speech-to-text, the LLM and text-to-speech. The user hears silence for the sum of whatever you did not overlap. LiveKit’s docs say a conversation feels natural when end-to-end latency stays under one second, and Pipecat’s intro says its pipelines typically land between 500 and 800 ms. Both are vendor statements, not guarantees, and neither knows your STT, your model or your region.
The Most Common Mistake
Building it like a chat app: wait for the full transcript, wait for the full completion, then synthesize the whole answer. Every stage waits for the previous one, you land in the 1,000 to 2,000+ ms that LiveKit’s blog gives for a naive blocking pipeline, and the first interruption makes it worse because nothing knows how to stop mid-sentence.
The fix is structural, not a faster model: stream at every stage, decide turn boundaries with something smarter than a silence timer, and treat interruption as a first-class event. That is what the frameworks below give you, and why I would not hand-roll this loop.
For the speech-to-speech alternative and its price per minute, see my Grok Voice 3.0 post. This one is the pipeline route, built by hand.
2. Minimum Concepts Before Touching Code
Six ideas explain nearly every knob you will touch.
VAD
the speech detector
A small local model flags speech versus silence frame by frame. LiveKit and Pipecat both use Silero on CPU. It feeds turn detection and interruption; it does not understand words.
Turn detection
when the user is done
A silence timer cuts people off mid-thought. LiveKit’s turn detector and Pipecat’s Smart Turn are models that weigh what was said and how before they close the turn.
Barge-in
the user talks over the agent
Playback stops, in-flight LLM and TTS work is cancelled and the history keeps only what the user actually heard. A backchannel such as “uh-huh” must not count as an interruption.
Streaming everywhere
overlap the stages
Interim transcripts, streamed tokens, TTS that speaks on the first text it gets. Per LiveKit’s blog, streaming brings total latency closer to the slowest stage than to the sum.
Preemptive generation
guess early
LiveKit starts the LLM as soon as a final transcript arrives, before the turn is confirmed, and discards the draft if the user keeps talking. It spends tokens to save time and is on by default.
Transport
where audio comes from
A WebRTC room for browsers and apps, a SIP trunk or a WebSocket media stream for phones. Same agent code, different front door.
Latency budget: a design tool, not a result
Typical values as the vendors state them. They are priors to replace with your own p50 and p95, not measurements of mine.
| Stage | Typical per vendor docs | Source | Metric to log |
|---|---|---|---|
| Network and transport | No figure in the sources I read | n/a | client-side timestamps |
| VAD | 10 to 50 ms | LiveKit blog, 2026-03-23 | VADMetrics |
| End-of-turn wait | 0.3 s minimum with the turn detector, 0.5 s without | LiveKit docs, read 2026-10-14 | end_of_turn_delay |
| STT transcript | About 200 ms per utterance; partials under 100 ms | LiveKit blog, 2026-03-23 | transcription_delay |
| LLM first token | 300 to 800 ms | LiveKit blog, 2026-03-23 | llm_node_ttft |
| TTS first audio | 100 to 200 ms with streaming | LiveKit blog, 2026-03-23 | tts_node_ttfb |
| Whole turn | 500 to 800 ms “in most cases” (Pipecat); 300 to 600 ms cascaded with streaming (LiveKit) | Pipecat docs, LiveKit blog, read 2026-10-14 | e2e_latency |
My arithmetic, not a measurement: add the single-number stages with zero overlap and you get 610 to 1,250 ms, plus the 300 to 500 ms end-of-turn wait, so roughly 0.9 to 1.75 s. The upper half of that range is above LiveKit’s own “under one second” bar, which is exactly why streaming overlap and preemptive generation exist. The blog’s 300 to 600 ms and 400 to 800 ms totals sit in different tables and are not reconciled with each other.
The mental model to keep
A voice agent is a pipeline with a clock. Every design choice either removes a wait (streaming, preemptive generation, a better end-of-turn signal) or removes a failure (noise cleanup, interruption rules). If a change does neither, it is not a voice-agent change.
3. LiveKit Agents vs Pipecat: What I Verified
Both are open-source Python frameworks for exactly this pipeline, and both are good. The table lists only facts I read in the official docs, source tags or package indexes on October 14, 2026.
I use LiveKit for the worked example for one reason: its quickstart is a complete single file, so I could check every call against the 1.8.7 source tag. Pipecat’s docs quickstart shows fragments, and its docs and quickstart repository currently use different class names (see the note below the table).
Verified facts, side by side
Read on 2026-10-14 from official docs, GitHub tags, changelogs and PyPI.
| LiveKit Agents | Pipecat | |
|---|---|---|
| Latest on PyPI | livekit-agents 1.8.7 (2026-10-14); a 2.0.0b1 pre-release also exists | pipecat-ai 1.12.0 (2026-09-26) |
| License and Python | Apache-2.0, Python 3.10 to 3.14 | BSD-2-Clause, Python 3.11+ |
| Transport | Agent joins a WebRTC room on a LiveKit server (Cloud or self-hosted) | Pluggable: Daily, LiveKit, SmallWebRTC peer to peer, WebSocket, WhatsApp |
| VAD | Silero plugin, runs on CPU | Silero on CPU, or Krisp VIVA VAD |
| Turn detection | Audio turn-detector model (v1 on LiveKit Inference, v1-mini on local CPU); VAD-only, STT endpointing or manual as options | Smart Turn model by default (LocalSmartTurnAnalyzerV3); a speech-timeout strategy as the simpler option |
| Barge-in | On by default; adaptive mode on LiveKit Cloud filters backchannel; history keeps only what the user heard; silent false interruptions resume | On by default; one InterruptionFrame cancels LLM, TTS and queued audio; only spoken text is kept; MinWords and mute strategies |
| Tools mid-call | @function_tool; async tools with ctx.update and filler; CANCELLABLE flag | Direct functions; @tool_options(cancel_on_interruption, timeout_secs) |
| Telephony | SIP trunks and dispatch rules, cold (REFER) and warm transfers, DTMF, US numbers on Cloud | PSTN and SIP through Daily; WebSocket serializers for Twilio, Telnyx, Plivo and Exotel (no transfers on that path, per the docs) |
| Hosting and pricing | Self-host the open-source server or use LiveKit Cloud; its pricing page lists agent sessions at $0.01 per minute beyond plan quotas (no effective date shown) | Self-host the framework, or Pipecat Cloud (Daily-hosted, usage-based, calculator on its pricing page); self-hosted regions via Enterprise |
| Quickstart | Complete agent.py; lk agent dev | pipecat init quickstart; uv run bot.py; client at localhost:7860 |
Pipecat naming: the docs build the pipeline with PipelineWorker and WorkerRunner, while the pipecat-quickstart repository’s bot.py I read still imports PipelineTask and PipelineRunner. The changelog records the rename and says the old names emit a DeprecationWarning. Take names from the docs, and expect older tutorials to use the old ones.
Pick on constraints, not on benchmarks
Already on Daily, or need to plug in an unusual transport? Pipecat’s pluggable transports are the draw. Want one SDK for rooms, agents, SIP and a hosted option? LiveKit. I did not benchmark either, and I know of no like-for-like comparison from the vendors that I would trust.
4. Step 1: Scaffold the Agent and Run the Baseline
Everything in this step comes from LiveKit’s Voice AI quickstart. I have not run it against a live project: I copied it from the docs and checked the imports and signatures against the livekit-agents 1.8.7 tag on GitHub.
Install the CLI, scaffold, run
Commands from the quickstart. console mode runs the agent in your terminal; dev connects it to LiveKit. Both need your LiveKit credentials because the models run on LiveKit Inference.
# 1) LiveKit CLI (pick your OS), then link your LiveKit Cloud project
winget install LiveKit.LiveKitCLI # Windows
brew install livekit-cli # macOS
curl -sSL https://get.livekit.io/cli | bash # Linux
lk cloud auth
# 2) Scaffold the official Python starter and run it
lk agent init my-agent --template agent-starter-python
cd my-agent
uv sync
lk agent console # talk to it in your terminal (add --text for text only)
lk agent dev # connect it to LiveKit and use the Agent Consoleagent.py: the STT-LLM-TTS baseline
The quickstart’s pipeline file. The agent_name is the name you enter in the Agent Console; the noise-cancellation plugin needs LiveKit Cloud, and the quickstart says to remove it and use provider plugins when you self-host.
from dotenv import load_dotenv
from livekit import agents
from livekit.agents import AgentServer, AgentSession, Agent, inference, room_io, TurnHandlingOptions
from livekit.plugins import ai_coustics
load_dotenv(".env.local")
class Assistant(Agent):
def __init__(self) -> None:
super().__init__(
instructions="""You are a helpful voice AI assistant.
You eagerly assist users with their questions by providing information from your extensive knowledge.
Your responses are concise, to the point, and without any complex formatting or punctuation including emojis, asterisks, or other symbols.
You are curious, friendly, and have a sense of humor.""",
)
server = AgentServer()
@server.rtc_session(agent_name="my-agent")
async def my_agent(ctx: agents.JobContext):
session = AgentSession(
stt=inference.STT(model="assemblyai/universal-3-6-pro", language="en"),
llm=inference.LLM(model="google/gemma-4-31b-it"),
tts=inference.TTS(
model="fishaudio/s2.1-pro",
voice="fa4c9eb3dccc4806b382b40d61c6b10a",
),
turn_handling=TurnHandlingOptions(
turn_detection=inference.TurnDetector(),
),
)
await session.start(
room=ctx.room,
agent=Assistant(),
room_options=room_io.RoomOptions(
audio_input=room_io.AudioInputOptions(
noise_cancellation=ai_coustics.audio_enhancement(model=ai_coustics.EnhancerModel.QUAIL_VF_S),
),
),
)
await session.generate_reply(
instructions="Greet the user and offer your assistance."
)
if __name__ == "__main__":
agents.cli.run_app(server)Read the file as a pipeline. inference.STT, inference.LLM and inference.TTS are the three streaming stages, served by LiveKit Inference so you need no provider keys but do need a LiveKit Cloud project. inference.TurnDetector() is the end-of-turn model, and the session provides the VAD it needs. The instructions tell the model to answer briefly with no emojis or asterisks, because the output is spoken; the session also strips markdown and emoji by default in 1.8.7.
Test in the terminal before you test on a phone
lk agent console gives you a voice or text session without a room, and --record saves the session to disk. Use it for prompt and tool iteration; reserve dev mode and real devices for latency and turn-taking checks.
5. Step 2: Turn-Taking, VAD and Barge-In
Turn-taking is where a voice agent feels human or robotic, and in 1.8.x it lives in one object: TurnHandlingOptions. This is the shape of LiveKit’s recommended starting config, trimmed to the parts that matter.
End of turn, interruptions, preemptive generation
Values are LiveKit’s documented defaults and starting point, not something I tuned. Change one at a time and watch the metrics from step 4.
from livekit.agents import AgentSession, TurnHandlingOptions, inference
session = AgentSession(
stt=inference.STT(model="assemblyai/universal-3-6-pro", language="en"),
llm=inference.LLM(model="google/gemma-4-31b-it"),
tts=inference.TTS(model="fishaudio/s2.1-pro", voice="fa4c9eb3dccc4806b382b40d61c6b10a"),
turn_handling=TurnHandlingOptions(
# audio model that predicts end of turn on top of VAD
turn_detection=inference.TurnDetector(),
# wait after detected silence before closing the turn
# (0.3 / 2.5 are the documented defaults with the audio turn detector)
endpointing={"mode": "fixed", "min_delay": 0.3, "max_delay": 2.5},
interruption={
"mode": "adaptive", # default on LiveKit Cloud; use "vad" when self-hosting
"min_duration": 0.5, # seconds of speech that count as a barge-in
"min_words": 0, # raise it (needs STT) to ignore "okay", "uh-huh"
"resume_false_interruption": True,
"false_interruption_timeout": 2.0,
},
# on by default; preemptive TTS trades wasted compute for lower latency
preemptive_generation={"preemptive_tts": False},
),
)Self-hosting or your own keys: swap in plugins
Plugin usage from each provider’s docs page (model ids are the docs’ examples; use what you run). The v1-mini turn detector runs locally on CPU at no cost; the full v1 model is served by LiveKit Inference.
# uv add "livekit-agents[silero,deepgram,openai,cartesia]~=1.8"
# env: DEEPGRAM_API_KEY, OPENAI_API_KEY, CARTESIA_API_KEY
from livekit.agents import AgentSession, TurnHandlingOptions, inference
from livekit.plugins import cartesia, deepgram, openai, silero
session = AgentSession(
vad=silero.VAD.load(),
stt=deepgram.STT(model="nova-3", language="en"),
llm=openai.responses.LLM(model="gpt-4.1"),
tts=cartesia.TTS(model="sonic-3", voice="f786b574-daa5-4673-aa0c-cbe3e8534c02"),
turn_handling=TurnHandlingOptions(
turn_detection=inference.TurnDetector(version="v1-mini"), # local CPU
),
)Three defaults worth knowing, all from LiveKit’s docs: with the audio turn detector the session closes a turn after 0.3 s minimum and 2.5 s maximum, against 0.5 s and 3.0 s without it; preemptive generation is on and preemptive TTS is off; and adaptive interruption handling, which filters backchannel with an audio model, is a LiveKit Cloud feature.
Spanish, and the 14-language list
The audio turn detector supports 14 languages, Spanish included. Set the STT language explicitly (for example inference.STT(language="es")) so the detector applies the right per-language threshold; with no STT it falls back to English thresholds.
6. Step 3: Function Calling Mid-Call Without Dead Air
A tool call in a voice agent is silence the user can hear. LiveKit gives you three behaviours, and picking the right one per tool is most of the job: a short read-only lookup you cancel if the user barges in, a write that must never end half done, and a slow job that keeps the agent talking.
Three tools, three interruption policies
Patterns from LiveKit’s function-tool and async-tool docs. The _lookup_order, _cancel_in_backend and _build_statement helpers are stubs for your own backend, with explicit timeouts.
import asyncio
from livekit.agents import Agent, RunContext, function_tool
from livekit.agents.llm import ToolError
class SupportAgent(Agent):
def __init__(self) -> None:
super().__init__(instructions="You are a phone support agent. Short sentences, no formatting.")
# 1) Short read-only lookup: stop the work if the user talks over the agent
@function_tool
async def get_order_status(self, order_id: str, run_ctx: RunContext) -> str | None:
"""Look up the status of an order when the user asks about it.
Args:
order_id: The ID of the order to look up.
"""
lookup = asyncio.ensure_future(self._lookup_order(order_id)) # your backend call
await run_ctx.speech_handle.wait_if_not_interrupted([lookup])
if run_ctx.speech_handle.interrupted:
lookup.cancel() # Python does not cancel it for you
return None # empty result, no reply
return lookup.result()
# 2) Write: a barge-in must not leave it half done
@function_tool
async def cancel_order(self, order_id: str, run_ctx: RunContext) -> str:
"""Cancel an order. Call only after the user confirmed out loud.
Args:
order_id: The ID of the order to cancel.
"""
run_ctx.disallow_interruptions()
ok = await self._cancel_in_backend(order_id) # your backend call, with a timeout
if not ok:
raise ToolError("The order could not be cancelled. Offer to transfer the caller to a person.")
return f"Order {order_id} is cancelled."
# 3) Slow job: acknowledge now, fill the silence, answer when done
@function_tool()
async def export_statement(self, ctx: RunContext, month: str) -> str:
"""Generate a statement and email it. Takes up to a minute.
Args:
month: Month to export, YYYY-MM.
"""
await ctx.update(f"Generating the statement for {month}. This can take a minute.")
async with ctx.with_filler("Still working on it, hang on a sec.", delay=5):
await self._build_statement(month) # your slow backend call
return f"The statement for {month} was sent to the email on file."What an interruption does to a tool
By default an interrupted tool keeps running in the background: LiveKit’s docs say interrupting the agent does not cancel the work. In Python you cancel it yourself, as the first tool does. Keep each agent to roughly 5 to 10 tools (LiveKit’s guidance), return speech-ready strings instead of raw JSON, and remember that max_tool_steps caps consecutive tool calls per turn at 3 by default.
7. Step 4: Measure Every Turn Before You Tune
LiveKit attaches a metrics report to every chat message. The fields map one to one onto the budget table, so you can see which stage ate the turn.
Per-turn latency from conversation_item_added
Field names from LiveKit’s metrics docs. Ship them to your logs or traces; I only print them here.
from livekit.agents import ConversationItemAddedEvent
from livekit.agents.llm import ChatMessage
@session.on("conversation_item_added")
def on_item(ev: ConversationItemAddedEvent):
if not isinstance(ev.item, ChatMessage):
return
m = ev.item.metrics # per-turn MetricsReport
if ev.item.role == "user":
print("stt:", m.get("transcription_delay"), "eot:", m.get("end_of_turn_delay"))
elif ev.item.role == "assistant":
print(
"llm ttft:", m.get("llm_node_ttft"),
"tts ttfb:", m.get("tts_node_ttfb"),
"e2e:", m.get("e2e_latency"),
)Log these per turn, then look at p50 and p95 per stage over a few dozen real calls from your target region. llm_node_ttft and tts_node_ttfb are only filled in pipeline mode. The 1.8.7 release notes added the same end-to-end latency for realtime agents when local VAD is configured. Change one knob at a time: LiveKit’s own tuning page warns that preemptive generation does not always reduce latency.
8. Phone Calls: The Hand-Off at a Conceptual Level
The agent code does not change when the caller is on a phone: a SIP caller is another participant in a room. What changes is the front door and what you do when the agent should stop.
The path of a call
- 1. A number or trunk receives the call: LiveKit Phone Numbers (US local and toll-free, on LiveKit Cloud) or your SIP provider’s trunk (LiveKit lists Twilio, Telnyx, Exotel, Plivo, Wavix, Sinch and didlogic as tested).
- 2. An inbound trunk authenticates it and a dispatch rule picks the room. A SIP participant joins and your agent server is dispatched into that room.
- 3. Use the telephony noise model for SIP callers and DTMF tasks for keypad input. The same turn-taking options apply.
- 4. Cold transfer: TransferSIPParticipant sends a SIP REFER and the caller leaves your room. A ringing timeout (30 s by default) keeps them with the agent if nobody answers.
- 5. Warm transfer: WarmTransferTask (Python, beta) holds the caller, briefs the human in a private room, connects them and drops out. A pipeline keeps text at every stage, so the summary is easy to produce.
The Pipecat side
Pipecat’s docs list Daily PSTN and SIP for call control and WebSocket serializers (Twilio, Telnyx, Plivo, Exotel) for simple call flows. They say the WebSocket path lacks advanced features such as transfers and reconnects.
9. Failure Modes: Echo, Noisy Rooms, Long Tool Calls
Demos hide these three. Production calls find them in the first week.
Echo: the agent interrupts itself
On loudspeaker the microphone hears the agent, VAD fires and the agent barges in on itself. Keep the client’s WebRTC echoCancellation on (LiveKit’s docs say it runs in the client only and can stay enabled next to agent-side noise cancellation), require min_words or min_duration so short blips do not count, and keep false-interruption resume on. Pipecat’s closest tool is its user-mute strategies, which discard user audio while the bot speaks. I found no vendor page on echo specific to SIP trunks, so test with a handset on speaker.
Noisy rooms: VAD hears speech, STT hears gibberish
Clean the audio before VAD. LiveKit offers voice isolation for one speaker and background noise suppression for several, with Krisp and ai-coustics models on LiveKit Cloud and a telephony variant for SIP. Raising the VAD activation_threshold (0.5 by default) is the blunt option and can miss soft speech; Pipecat’s docs say the same about confidence and min_volume. Never apply the same cleanup in the frontend and in the agent.
Long tool calls: silence and double actions
A blocking tool is dead air. Use ctx.update and a filler for anything past a few seconds, set explicit HTTP timeouts, and set on_duplicate so an impatient “hello?” does not trigger a second booking. In Pipecat the equivalents are cancel_on_interruption=False and timeout_secs in @tool_options.
10. Common Mistakes (and the Boring Fixes)
Most voice-agent bugs I read about fall into these buckets. The left column is what holds up; the right column is what pages you.
✅ Do this
- • Configure turn-taking through turn_handling=TurnHandlingOptions(...) and pin livekit-agents~=1.8
- • Start at the documented defaults and move min_delay one step at a time
- • Call run_ctx.disallow_interruptions() at the start of any tool that writes
- • Return speech-ready strings and raise ToolError with a message the agent can say
- • Tell the model to speak in short sentences with no markdown or emoji
- • Log end_of_turn_delay, llm_node_ttft, tts_node_ttfb and e2e_latency per turn
❌ Not this
- • Pass min_endpointing_delay, allow_interruptions or turn_detection straight to AgentSession: deprecated in 1.8.7, removal targeted for v2.0
- • Ship a 20-second tool with no update or filler and call the silence “thinking”
- • Enable noise cancellation in both the frontend and the agent
- • Set the Silero min_silence_duration under 0.25 s with the audio turn detector (the docs say it raises a ValueError at start)
- • Assume an interruption cancels your backend call
- • Copy a vendor latency figure into your SLA without measuring it
Golden rule
Budget first, then tune. Put your p95 per stage next to the design table before you touch a knob, and let one metric justify each change.
Conclusion
You now have the full loop: a streaming STT → LLM → TTS pipeline on LiveKit Agents, turn-taking you can reason about, tools that behave under interruption, a phone path in outline and a metric for every stage. Pipecat would give you the same shape with different names and a different transport story, and the comparison table is there so you can choose without a benchmark war.
This week: scaffold the quickstart, add one read-only tool with interruption handling, log the four per-turn metrics for 20 calls and fill in your own budget table. Then decide whether the pipeline or the speech-to-speech model from my previous post wins on your traffic.
Tutorial Cheat Sheet
Turn-taking
- • TurnHandlingOptions(...)
- • inference.TurnDetector()
- • adaptive or vad interruptions
Tools
- • @function_tool + RunContext
- • disallow_interruptions() for writes
- • ctx.update + with_filler
Measure
- • end_of_turn_delay
- • llm_node_ttft + tts_node_ttfb
- • e2e_latency p50 / p95



