LiveKit vs Vapi, and why you should use LiveKit

We build a real-time voice AI agent that answers phone calls for fitness studios. It books classes, cancels memberships, freezes accounts, and answers questions from a knowledge base, all in a live conversation where a human is waiting on the other end of the line.

We started on Vapi. We moved to LiveKit. This post explains the difference between the two, what each one owns in your stack, and why a hosted voice platform eventually becomes the thing you are fighting instead of the thing you are building on.

Two Different Layers of the Stack

The first thing to understand is that Vapi and LiveKit do not actually sit at the same layer, even though they are pitched as alternatives.

Vapi is a hosted voice orchestrator. It owns telephony, speech-to-text, text-to-speech, and turn-taking. Your application is a webhook. The caller talks to Vapi, Vapi runs the voice loop, and it calls back into your backend for the model response.

LiveKit is real-time media infrastructure plus an agents framework. It gives you the WebRTC transport, a SIP bridge, and a Python (or Node) SDK where you assemble the voice loop yourself from components you choose. Your application is the agent process. It runs the loop.

Here is what our legacy Vapi flow looked like:

graph LR
    Caller["Caller"] --> Twilio["Twilio"]
    Twilio --> Vapi["Vapi (hosted voice stack)"]
    Vapi --> Backend["Sunday backend /chat/completions"]
    Backend --> OpenAI["OpenAI + tool execution"]
    OpenAI --> Vapi
    Vapi --> Caller

Vapi owned telephony, STT, TTS, and turn orchestration. Our backend owned the prompt, tool execution, and the streaming response. The boundary between "our logic" and "their platform" ran straight through the middle of every conversation.

Here is the LiveKit flow that replaced it:

graph LR
    Caller["Customer"] --> PSTN["PSTN / SIP Trunk"]
    PSTN --> SIP["LiveKit SIP Bridge"]
    SIP --> Room["LiveKit Room"]
    Room --> Agent["sunday-phone-agent"]
    Agent --> Backend["Sunday Backend (config + call-completed)"]
    Agent --> KB["Turbopuffer (KB tools)"]
    Agent --> R2["Cloudflare R2 (recordings)"]

The difference is not cosmetic. In the Vapi version, the conversation lives inside Vapi and pokes our backend. In the LiveKit version, the conversation lives inside our own process and pulls in exactly the services we want. That inversion is the whole argument.

1. You Own the Pipeline, Component by Component

With Vapi you configure a pipeline. With LiveKit you build one. The entire STT, LLM, TTS, and VAD stack is assembled in code, and every component is a swappable provider you choose:

session = AgentSession(
    stt=stt_provider(
        model=stt_model,
        eager_eot_threshold=...,
        eot_threshold=...,
    ),
    llm=llm_provider(
        model=llm_model,
        reasoning=Reasoning(effort=...),
        service_tier=...,
    ),
    tts=tts_provider(
        model=tts_model,
        voice=tts_voice,
        speed=tts_speed,
    ),
    turn_handling=TurnHandlingOptions(
        turn_detection="stt",
        interruption={"min_duration": 0.2},
    ),
    vad=ctx.proc.userdata["vad"],
    userdata={...},
)

Each component is a separate provider, chosen on its own merits, and any one of them can be replaced without touching the rest of the system. When a better TTS model ships next quarter, that is a one-line change, not a platform migration.

This matters more than it sounds. On a hosted platform you get the providers the platform integrated, at the version the platform pinned, with the parameters the platform exposed. Notice the parameters above that we actually tune: the end-of-turn thresholds on the STT control how aggressively we finalize a turn, the reasoning effort on the LLM trades latency for quality, and the service tier buys us faster inference. Those are the knobs that decide whether a call feels natural or laggy, and on LiveKit they are ours.

2. Multi-Step Workflows as Scoped Sub-Agents

This is the capability that made the migration non-negotiable for us. Our agent does not just answer questions. It runs procedures: cancel a membership, freeze an account between two dates, book a class and take payment. Each procedure has steps, consent gates, and a strict order of operations.

LiveKit Agent Tasks let us model each procedure as a sequence of tightly scoped sub-agents. A task has its own instructions, its own tools, and a typed result. You await it like a function, the caller converses with the agent under the task's narrow scope, and the task returns structured data when it completes.

dates_task = CollectFreezeDatesTask(...)
dates_result = await dates_task

freeze_consent_task = FreezeConsentTask(
    freeze_start=context.session.userdata["freeze_datetime"],
    freeze_end=context.session.userdata["reactivation_datetime"],
    ...
)
consent_result = await freeze_consent_task

result = await membership_tools.run_freeze(
    membership_refs=membership_refs,
    agent_id=agent_id,
    freeze_datetime=freeze_dt,
    reactivation_datetime=reactivation_dt,
)

Read the order carefully, because it is the point. The two await calls are conversations. The agent collects the freeze dates, then gets explicit consent, each under instructions scoped to just that step. The actual freeze, the irreversible state change, happens in plain Python between the tasks. The language model never decides whether to call the freeze API. It only gathers the information and confirms intent. Our code makes the call, unconditionally, once the prerequisite tasks succeed.

That separation is hard to express on a platform where the model loop is the product. On LiveKit it is just control flow. The tasks handle conversation, the trigger function handles the deterministic, auditable, money-touching operations, and the boundary between them is a line of code we can point to.

Every task also inherits a built-in escape hatch for when the caller wanders off topic mid-procedure:

class SundayAgentTask(AgentTask[TaskResultT], ABC):
    @abstractmethod
    def caller_changed_topic_failure(self) -> TaskResultT:
        """Return the task result used when the caller switches to an unrelated topic."""

    @function_tool()
    async def caller_changed_topic(self, context: RunContext) -> None:
        logger.info("%s: caller changed topic", self.__class__.__name__)
        self.complete(self.caller_changed_topic_failure())

A caller halfway through a freeze can ask "wait, what time do you close today?" The task completes with a failure result, control returns to the main agent, the question gets answered, and nothing half-finished is left behind. We get to define that behavior because we own the loop.

3. Tools Are Just Functions, and So Is the Orchestration

A tool in LiveKit is a decorated Python function. There is no remote tool registry, no separate config surface, no webhook contract to keep in sync. The function signature is the schema, the docstring is the description, and the body is the implementation:

def _make_booking_task_tool(config: AgentConfig):
    @function_tool()
    async def handle_booking(
        context: RunContext,
        class_session_id: int,
        class_description: str,
    ) -> str:
        """Book a class for the caller..."""
        play_filler(context)
        lookup, err = membership_tools.resolve_membership_lookup(context)
        if err or not lookup or not lookup.phone_e164:
            return json.dumps({
                "booked": False,
                "agent_guidance": membership_tools.GUIDANCE_NO_RESOLVABLE_PHONE_BOOKING,
            })

        consent_task = ConsentBookClass(...)
        consent = await consent_task
        return await _booking_run_pipeline_after_consent(...)

    return handle_booking

Because the trigger is ordinary code, branching logic that would be awkward in a hosted flow builder becomes trivial. The trigger reads the member's situation, picks the right task to run, and then chooses the right API call based on the task's typed result:

context_data = load_member_context(...)

if requires_special_handling(context_data):
    task = EscalationTask(...)
else:
    task = StandardTask(...)

result = await task

if result.path_a:
    outcome = await post_action_a(...)
elif result.path_b:
    outcome = await post_action_b(...)

The model is not deciding the rules here. Our code reads the member's state, picks the right task, and chooses the right API call based on the typed result. The model conducts the conversation. The code enforces the policy. On a platform that owns the loop, expressing branching like this means bending the platform's flow primitives. In LiveKit it is an if statement.

4. Native SIP, Your Telephony Provider of Choice

Calls reach the agent through LiveKit's SIP bridge. We bring our own SIP trunk, which is our choice, not a platform default. A SIP dispatch rule targets the agent by name and carries the agent's UUID as job metadata, which is how a single agent binary serves many tenants:

Caller --> SIP trunk (LiveKit)
  --> SIP dispatch rule targeting "sunday-phone-agent"
  --> metadata = agent_id (UUID)
  --> Agent reads ctx.job.metadata --> fetches config

Inside the agent we work with the SIP participant directly. Reading the dialed number, transferring the call, all of it is first-class API surface rather than a setting in someone's dashboard:

for p in ctx.room.remote_participants.values():
    if p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP:
        sip_participant = p
        break

to_number = sip_participant.attributes.get("sip.phoneNumber")

When we need to hand a caller to a human, we transfer the underlying SIP participant ourselves:

await job_ctx.api.sip.transfer_sip_participant(
    api.TransferSIPParticipantRequest(
        room_name=job_ctx.room.name,
        participant_identity=sip_participant.identity,
        transfer_to=f"tel:{phone_number}",
    )
)

Owning the SIP layer means we can also tap the raw media for things the platform never anticipated. Call recording is a LiveKit Egress straight to our own Cloudflare R2 bucket, no third-party recording feature required:

await lkapi.egress.start_room_composite_egress(
    api.RoomCompositeEgressRequest(
        room_name=ctx.room.name,
        audio_only=True,
        file_outputs=[api.EncodedFileOutput(
            file_type=api.EncodedFileType.MP4,
            filepath=f"call-recordings/{ctx.room.name}.mp4",
            s3=api.S3Upload(bucket=r2_bucket, ...),
        )],
    )
)

5. Latency Optimizations You Could Not Do From a Webhook

On a voice call, latency is the product. A hosted platform gives you the latency it gives you. Because we own the loop, we can attack dead air at points a webhook integration cannot even reach.

When the model is about to call a slow tool, we play a short filler phrase from a pre-synthesized cache so the caller hears "let me check that for you" instead of silence:

def play_filler(context: RunContext):
    filler = context.session.userdata.get("filler")
    phrase = filler.next()
    tts_cache = context.session.userdata.get("tts_cache")
    frames = tts_cache.get_frames(phrase) if tts_cache else None
    if frames:
        async def audio_gen():
            for frame in frames:
                yield frame
            for frame in pause:
                yield frame
        handle = context.session.say(phrase, audio=audio_gen(), add_to_chat_ctx=False)

The greeting and common transfer announcements are synthesized once and cached as raw audio frames, so the first words out of the agent's mouth have zero TTS latency:

class TTSCache:
    async def warmup(self, phrases: list[str]) -> None:
        to_synth = [p for p in phrases if p not in self._cache]
        sem = asyncio.Semaphore(WARMUP_CONCURRENCY)
        async def _bounded(phrase: str) -> None:
            async with sem:
                if phrase not in self._cache:
                    await self._synthesize(phrase)
        await asyncio.gather(*(_bounded(p) for p in to_synth))

Most importantly, every expensive setup step runs in parallel during call connection, before the caller has said anything. We warm the TTS cache, warm the knowledge base vector namespaces, build the tool set, and fetch the member's profile all at once:

_, _, tools, member_data = await asyncio.gather(
    _timed_task("tts_cache.warmup", warmup_timings, tts_cache.warmup([greeting_message])),
    _timed_task("warmup_kb", warmup_timings, _warmup_kb()),
    _timed_task("build_tools", warmup_timings, _build_tools()),
    _timed_task("fetch_member_profile", warmup_timings, _fetch_member_profile_safe()),
)

By the time the caller finishes saying hello, the knowledge base is hot, the tools are built, the member is identified, and the greeting audio is ready to play. None of these optimizations are possible when the conversation lives on someone else's servers and your code is a callback.

6. Per-Call Configuration for Multi-Tenancy

One agent binary serves every studio. The configuration is not baked into a build or stored in a vendor dashboard. The agent fetches it at call start using the agent_id that arrived as job metadata:

config = await fetch_agent_config(agent_id)
logger.info(
    f"Loaded config for agent {config.agent_name} "
    f"({len(config.knowledge_bases)} KBs, {len(config.api_tools)} API tools, "
    f"{len(config.keywords)} keywords)"
)

Each studio gets its own system prompt, its own knowledge bases, its own tools, its own voice, and its own STT keyterms, all resolved at runtime from our own backend. Adding a tenant is a database row, not a new project in a platform console. This is the kind of architecture that is natural when you own the agent process and awkward when you are configuring a hosted product.

7. Observability You Define

Because the agent is our own process, we instrument it like any other production service. OpenTelemetry traces the whole thing, and we add spans for the operations we actually care about, with the attributes we actually want to slice on:

with _tracer.start_as_current_span("phone_agent.transfer") as span:
    span.set_attribute("transfer.kind", transfer_kind)
    span.set_attribute("transfer.announcement_cache_hit", cache_hit)
    span.set_attribute("transfer.sip_participant_found", bool(sip_participant))
    span.set_attribute("transfer.result", result_value)

We measure our own warmup breakdown, TTS frame counts, transfer cache-hit rates, and tool execution times, exported to our own observability stack. On a hosted platform you get the metrics the platform surfaces. Here we get the metrics we decided mattered, named the way our dashboards expect.

Vapi vs LiveKit, Side by Side

Concern Vapi LiveKit
What it is Hosted voice orchestrator Real-time media infra plus agents SDK
Where the loop runs Vapi's servers Your own process
STT / LLM / TTS / VAD Platform-integrated choices Any provider, swapped in code
Your app's role A webhook The agent itself
Multi-step procedures Flow builder primitives Scoped Agent Tasks, plain control flow
Deterministic API calls Inside the model loop Your code, between tasks
Telephony Bundled Native SIP, bring your own trunk
Latency control What the platform offers Filler audio, TTS cache, parallel warmup
Recording Platform feature LiveKit Egress to your own storage
Observability Platform metrics Your own OpenTelemetry spans
Multi-tenancy Per-agent config in dashboard Per-call config from your backend

When Vapi Is Actually the Right Call

This post argues for LiveKit, but the honest version has a boundary. Vapi is genuinely faster to a working prototype. If you want a single agent that answers FAQs and books a meeting, and you do not need to own the latency budget or run irreversible operations mid-call, a hosted platform will get you live in an afternoon. We kept a legacy Vapi path running for exactly this reason while we migrated.

The trade tips toward LiveKit when your agent stops talking and starts doing. The moment you have multi-step procedures with consent gates, money-touching operations that must stay out of the model's hands, latency you need to fight at the frame level, observability your on-call team depends on, and many tenants behind one binary, the hosted platform stops saving you work and starts constraining it. That is the threshold we crossed, and it is why our active path is LiveKit and our Vapi path is labeled legacy.

Key Takeaways

  1. Vapi and LiveKit are different layers. Vapi runs the conversation and calls your webhook. LiveKit gives you the media and the SDK, and your process runs the conversation. Everything else follows from that inversion.
  2. Own the pipeline. Choosing STT, LLM, TTS, and VAD independently means you pick the best component for each job and upgrade any one of them without a migration.
  3. Model procedures as scoped tasks. Agent Tasks let the model handle conversation while your code handles the irreversible operations, with a clean line between them.
  4. Keep critical API calls out of the model loop. The trigger function calls the freeze, cancel, or booking endpoint deterministically once the prerequisite tasks succeed. The model never decides.
  5. Latency is a feature you build. Filler audio, pre-synthesized TTS caches, and parallel warmup at call connect are only possible when you own the loop.
  6. Configure per call. Fetching agent config at runtime turns one binary into a multi-tenant fleet.
  7. Instrument it like a real service. Your own OpenTelemetry spans beat whatever metrics a platform chooses to surface.

A hosted voice platform is the fastest way to a demo. An owned voice loop is the only way to a product that runs procedures, fights for every millisecond, and answers to your own dashboards. Start on the platform if you must. Build on LiveKit when you mean it.