Skip to main content
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.
That names a package sitting somewhere else, which is why it carries a path. The argument is optional: inside a package, 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

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.
Pipecat browser development needs 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 dev asks 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.
switch, no value
Print the runtime’s own logs as well as the run’s. See Logs.

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:
The web page URL always carries the target name, for example 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 always build/<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:
The file holds the local runtime’s output and the reason a start failed. Raw dev records include recognized and generated transcript text. The visible measurement filter leaves out repeated transcript fragments. The file is rewritten on every run, so the file you are looking at is always this run. To follow the same output in your terminal while it runs, add --verbose. The flag means “follow container/agent logs on stderr”, and without it the log goes to the file only:

Not opening the browser

The URL is still printed. Useful over SSH, and in scripts.

Environment

unmute dev builds the run’s environment in this order, with later files winning:
  1. your shell environment
  2. .env in the directory you run the command from
  3. .env.local in the directory you run the command from
  4. .env in the package directory
  5. .env.local in the package directory
So a repository wide .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
Leave the flag out and the 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.
This seeds the fact, which a 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

Each unmute 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:
A run stops its own stack when it ends, including on ctrl-c and on a closed terminal. A stack whose 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.