unmute dev is the loop you live in while building an agent. One command
compiles the package, starts the target’s local runtime, and serves a web page
you can talk through.
unmute dev on its own runs the
directory you are standing in, and every path it prints is relative to there.
On this page:
- The one way to run - one command
- What it does, in order - the five things it runs
- Every flag - targets, ports, seeding
- The two views - the transcript and the timings
- Advanced - environment, seeding, more than one session
The one way to run
A phone call reaches an agent that is deployed, so telephony is verified after you deploy. Phone calls covers what a route is. Deploying to LiveKit Cloud or Pipecat Cloud puts an agent where a real call can reach it.What it does, in order
1
Compile
The package is generated into
build/<target>/. You do not run unmute compile first.2
Start the runtime
Pipecat starts the generated
bot.py directly with uv. This keeps its
WebRTC ICE candidates reachable from the host browser. LiveKit uses Docker
Compose because its stack also includes a local LiveKit server.3
Serve the page
A small local web server opens your browser straight away, before the
runtime is up. The page shows the startup output while it waits, so a build
that fails is something you read rather than guess at.
4
Wait for ready
Pipecat waits for its
/status response. LiveKit waits for the worker to
register, up to three minutes. The call button stays unavailable until then,
and the page moves to the conversation view once a call would be answered.5
Clean up
Ctrl-c stops the local Pipecat process or removes the LiveKit stack. LiveKit
data volumes are kept.
uv. LiveKit browser development needs
Docker with Compose.
Picking a target
A package with one target needs no flag. With more than one:- In a terminal,
unmute devasks which target to run. - Without a terminal, for example in a script, it refuses and tells you the choices:
--target takes exactly one name here, unlike validate and compile, which
take a repeatable list. A name that is not declared is an error:
Every flag
target name
Which target to run. Needed when
targets.yaml declares more than one. See
Picking a target.number
default:"8765"
The local web page you talk through.
number
default:"7860"
The host port the local agent runtime uses.
name=value
Seed a declared variable before the call. See
Seeding variables.
fact=value
Stand in for a fact the phone network would supply, such as
from_number. See Seeding call facts.switch, no value
Do not open a browser. See Not opening the browser.
Ports
When a default port is busy, most often because another session holds it, a free one is chosen and the printed URL names it. Pass the flag to pin a port; a pinned port that is busy is a refusal:http://localhost:8765/?agent=pipecat.
The two views
The page has two views and one set of call controls that stays put across both, so switching never interrupts a call.
Recognized words appear as they arrive. Each finalized part becomes normal text
immediately, even while the model is still silent. The agent’s text also grows
as it is generated. Generated words can lead audio playback; the page keeps
those states separate and marks interruptions or incomplete replies.
Model and tool activity appears before answer text. Each tool call has its own
row, even when two calls use the same name. Reported outcomes update that row;
returned means a result arrived, not that the business action succeeded.
A call into a task, or a handoff to another agent, gets its own row too,
labelled HANDOFF, and carries no duration: it hands control over rather than
returning a result. That row is what accounts for the model call that follows
it.
A reply summary shows its reported latency and model-call count. Each LLM 1,
LLM 2, and later row shows its own first-response and full-duration values
without expanding anything. TTS first-audio time and tool duration stay visible
too. Debug details holds secondary timings and source/model details. See
Reading the latency numbers for definitions. Counts appear only after
identified model calls arrive.
Only captured measurements appear. Missing and pending values have no
placeholder; a measured zero is 0ms. Small positive values stay positive:
1ms, or <1ms below one millisecond. Timing values can overlap, so adding
stage values does not reconstruct reply latency.
The conversation follows new content until you scroll back. Latest returns
to the newest content with one action. Details keep their open state and work
with Enter or Space. Ending a call retains its words and marks unfinished text;
starting another call clears that history.
Live-data status is separate from audio status. Reconnecting the event feed
does not restart the microphone. If some history cannot be recovered, the page
keeps the incomplete-history label until the next call and shows observed model
calls labelled as observed. Late updates stay with their known exchange;
unassignable values remain labelled as unassigned. Call diagnostics, below
the conversation, holds those values and call-level first-speech measurements.
It starts collapsed and appears only when there is data to inspect; it does not
insert diagnostic sections between turns.
The HANDOFF row carries no duration on purpose. A task, a task group, or a
handoff to another agent reaches the framework as a function call, so it could
be timed like a tool. But a task or a task group does not return until
everything it started has finished, and a handoff never returns at all. Timed
that way, a three-turn task would appear as one very slow tool, which is the
opposite of what the numbers are for. The row marks the moment control moved,
and the model call that follows it is timed on its own.
Reading the latency numbers covers what each number means, where
the time goes in a voice turn, and how to tell which part is slow.
The logs view is in front while the target is starting, and the conversation
view takes over once the agent is ready. If anything in the startup output looked
like a failure, the logs view stays in front instead, and the count of those
lines sits on the tab so it is visible from either view. Pick a view yourself and
the page stops moving it for you.
Filter the output by typing, or narrow it to output, measurements, or problems.
There is no log-level parsing behind those buttons, just a match on the text,
which is why it does not break when a provider reformats a line.
A failed start is now visible in the browser. The page stays up so you can
read the output, the terminal still prints the error and the log path, and the
command still exits non-zero.
Advanced
Logs
Every run writes its log into the build directory, and prints the path when it starts. That file is the first place to look when the page loads but nothing speaks, and it is where a failure to start explains itself. It holds everything the process printed, measurement lines included, so it always matches the run. The log file is alwaysbuild/<target>/dev.log. The path is printed with the
ready line, exactly as you will see it. From inside a scaffolded package, run
with no argument, that reads:
--verbose. The flag
means “follow container/agent logs on stderr”, and without it the log goes to the
file only:
Not opening the browser
Environment
unmute dev builds the run’s environment in this order, with later files
winning:
- your shell environment
.envin the directory you run the command from.env.localin the directory you run the command from.envin the package directory.env.localin the package directory
.env can hold shared keys while .env.local overrides
it, and one package can override both. The generated
build/<target>/.env.example lists exactly what you supply.
Seeding variables
--var name=value stands in for the values a production call would arrive
with. It is repeatable, and it only accepts variables declared with
source: call_start:
agent.yaml
default is used instead. See
variables for what happens when you try to seed something
else.
Seeding call facts
--source name=value stands in for a fact the call itself carries, such as
the caller’s number. It is repeatable, and it only accepts the eight facts a
call carries: from_number, to_number, call_id, direction, carrier,
connection, session_id, stream_id.
prefetch: entry then reads, so the run
exercises the pre-fetch, the confirmation marking, and the read back. On a
real call the carrier’s own value wins: a seed only fills in what the route
gave nothing for.
Which facts a real call actually carries depends on the route: LiveKit’s two
routes grant the most, and Pipecat’s two Twilio routes grant a smaller set,
one direction only for a phone number. Seed whichever your target’s route
grants; Where it works has the full grid.
Do not use --var to seed a caller’s number. --var writes the variable
directly, so it skips the pre-fetch, marks nothing as awaiting confirmation,
and lets a local run act on a number it never read back. That is a path a
real call cannot take.
More than one session at a time
Eachunmute dev run of a LiveKit package is its own Compose project, named
from the package path and the target. Two runs from the same path share one
project, so the second replaces the first. Two runs from different paths, such
as two checkouts of the same agent, run side by side: each picks a free set of
LiveKit server ports and a free page port.
To pin the LiveKit ports yourself, move signaling, TCP fallback, and UDP media
as one set:
unmute dev process was killed outright is stopped by
the next run, which says so. When a stack cannot be stopped, the run prints the
docker compose command to run by hand.
A second Pipecat run picks a free agent port the same way when --bot-port is
not passed.
Where to go next
Tracing
See the whole call after it ends: transcript, tool calls, and per-span
timing.
Phone calls
What each route means, and how a call reaches a deployed agent.
dev reference
Every flag, with defaults and requirements.
Latency
What each number under a turn means, and which part to fix.