unmute
Health Pass
- License — License: MIT
- Description — Repository has a description
- Active repo — Last push 0 days ago
- Community trust — 15 GitHub stars
Code Pass
- Code scan — Scanned 12 files during light audit, no dangerous patterns found
Permissions Pass
- Permissions — No dangerous permissions requested
No AI report is available for this listing yet.
Voice agents in YAML, compiled to Pipecat or LiveKit. Production code you own, deployed anywhere.
One voice agent spec. Compiled to the runtime you pick.
Unmute is a command line compiler for voice agents. You write a small package of
YAML and Markdown that says who the agent is, which models it uses, which tools
it can call, what it saves as the call goes on, and how the work is broken into
steps. Unmute turns that into a real Python project for the orchestrator you
picked, and refuses to compile the mistakes you would otherwise find on a live
call.
The project it writes is yours. Pinned dependencies, a Dockerfile, a runbook, and
no dependency on Unmute at runtime. Unmute compiles ahead of time and gets out of
the way. It is never in the call path.
[!TIP]
The Quickstart goes from nothing
to a voice you can talk to in your browser. The full guide lives at
unmute.ai.
Quickstart
brew install slng-ai/tap/unmute # macOS
go install github.com/slng-ai/unmute@latest # anywhere with Go 1.26+
unmute init my-agent # agent.yaml, instructions.md, targets.yaml, tools/, .env.example
cd my-agent
cp .env.example .env # then fill in OPENAI_API_KEY and SLNG_API_KEY
unmute validate
unmute dev # compile, run the container, open the browser
Allow the microphone and say hello. The agent speaks first, because agent.yaml
says so.
Windows uses Scoop, Linux takes the archive from the
releases page, and every release
also carries a signed checksums.txt and one SBOM per archive.
Installation covers all four
ways, plus the one thing running an agent needs: Docker with Compose for the
LiveKit browser loop, or uv for Pipecat.
An agent is a file
This is a whole agent.
# agent.yaml
version: 1
name: my-agent
entry_agent: assistant
agents:
assistant:
instructions: instructions.md
think: assistant_model
speak: assistant_voice
tools:
- end_call
secrets:
- OPENAI_API_KEY
- SLNG_API_KEY
models:
think:
assistant_model:
provider: openai
model: gpt-5.6-terra
params:
# A reasoning model answering with function tools on chat completions
# needs this exact value, or OpenAI returns 400 on every turn.
reasoning_effort: "none"
speak:
assistant_voice:
provider: slng
model: "deepgram/aura:2"
voice: "aura-2-thalia-en"
params:
# Speech goes through the SLNG gateway nearest your callers.
world_part: eu-north
listen:
transcriber:
provider: slng
model: "deepgram/nova:3"
params:
world_part: eu-north
turn:
detector:
provider: local
model: silero
tools:
- end_call
conversation:
greeting:
speaks_first: agent
text: "Hi, how can I help?"
channels:
web:
kind: realtime_audio
capacity:
peak_sessions: 10
max_sessions: 20
avg_session_duration: 5m
Three things to notice:
- The prompt is a Markdown file next to it, not a quoted string buried in YAML.
- Every model is named once and then referenced by name. Point
assistant_model
at a different model and every agent using it follows. - Nothing here mentions Pipecat or LiveKit. That choice lives in its own file.
# targets.yaml
targets:
pipecat:
provider: pipecat
version: "1.10.0"
livekit:
provider: livekit
version: "1.8.1"
sdk_language: python
models:
detector:
provider: livekit
model: turn-detector-mini
One agent, two targets, and unmute compile writes both projects. Where a
runtime cannot run a model as defined, it overrides that single entry by name,
the way LiveKit does with the turn detector above. The agent itself does not
change.
Three targets
| Target | Kind | What you get |
|---|---|---|
| LiveKit Agents | code | build/livekit/agent.py, a Python project you host and run |
| Pipecat | code | build/pipecat/bot.py, plus a pcc-deploy.toml for Pipecat Cloud |
| SLNG | hosted | unmute deploy pushes a deployment body and SLNG runs the agent |
pipecat, livekit and slng are the only values provider accepts. A code
target gives you something to host. The hosted target has nothing to host, and
no unmute dev.
What compile writes
One directory per target, under build/.
build/livekit/
├── agent.py # the agent
├── tools/ # your local Python handlers, copied
├── dev_metrics.py # per-turn timings, read by unmute dev
├── pyproject.toml # pinned dependencies
├── Dockerfile
├── .dockerignore
├── compose.dev.yaml
├── .env.example # exactly the variables you supply
├── README.md # the runbook for this build
└── compile-report.json
Pipecat gets the same shape with bot.py as its entry point. Neither project
imports Unmute.
Treat build/ as output. Edit the package and compile again. Anything you change
inside build/ is overwritten on the next compile.
What else the file holds
The agent at the top of this page is the floor. Everything below is written in
the same agent.yaml, next to what you have already read, and every piece of it
is checked before a call happens rather than during one. The snippets are fromcustomer-intake andsalon-concierge.
Values with a type
A variable says what it holds. The model is told the format, and the value is
checked where it enters, not where it is used.
shapes:
- name: CustomerRecord
description: The record the intake desk opened for this caller.
fields:
- record_id: Id
- opened_on: Date
- enquiry: Literal["new_customer", "existing_customer", "complaint", "other"]
variables:
contact:
type: NameEmail
description: >-
Who the caller is and where their confirmation goes, the name and the
email address held as two separate parts.
caller_email:
type: EmailStr
default: ""
description: The caller's email address on its own, with no name around it.
callback_time:
type: Time | None
description: >-
A good time of day to ring the caller back, on the 24 hour clock. Leave it
out when the caller has not named one.
notes:
type: list[str]
description: >-
Anything the caller added that no other field holds, one short entry per
thing they said.
Phone, Date, Time, Id and EmailStr are text with a checked shape.NameEmail holds a name and an address as two separate parts, so a prompt can
use the name without reading the address out loud. You also have str, int,float, bool, Literal[...], list[...], | None, and any shape you
declare yourself.
An address the model misheard is refused with the format, inside the same turn,
so the model fixes it rather than saving something wrong. The email check never
asks DNS: this runs while the caller is on the line, and a slow resolver would
hold them in silence.
A step takes one part of a result with a dotted path, and + appends instead of
replacing.
assign:
- contact: result.contact
- caller_email: result.contact.email
- notes+: result.note
A step that ends on its own tool
One task out of an agent's tasks: list.
- name: manage_booking
instructions: tasks/booking.md
tools:
- find_slots
- save_booking
finish:
- tool: save_booking
success:
- status:
- booked
- moved
- cancelled
assign:
- appointment: result.appointment
context:
history: messages
finish: names the tool calls that end the step, and the result value that
counts as success. The booking goes through and the step is over. No extra model
call to announce it, no second sentence, and none of those tools runs again in
that step, because a second booking that ran and was then refused is still a
second booking. The caller hears the owning agent reply once.
The success value has to be one the tool's own output declares in an enum:. A
field that cannot hold it is refused when you compile, with the file and the
line.
A group that skips work already done
task_groups:
book:
when: >-
The caller wants to create, move or cancel a booking. Includes a change to
an appointment just made.
steps:
- task: verify_customer
skip_when_confirmed: customer_phone
- manage_booking
context_scope: shared
then: return
Verification runs the first time and is skipped once the number is agreed.skip_when_confirmed: has to name a value that this same step confirms, so the
two cannot drift apart. Entering the step again withdraws what it confirmed,
which is what a caller correcting their number needs.
Facts in the prompt before the caller speaks
prefetch:
- name: today
clock: now
timezone: Europe/Madrid
assign:
- today_date: result.date
- name: caller
source: from_number
assign:
- caller_phone: result.value
Each entry runs before the greeting, on its own budget, and cannot raise. When
one skips, the variable keeps its default and the step that asks runs instead.
The zone sits on the entry, because a container's clock is UTC and would name
the wrong day for everybody who is not on it.
A value the model never retypes
# tools/create_customer_record.yaml
input:
type: object
properties:
summary:
type: string
description: One sentence in the caller's own words saying what they rang about.
required:
- summary
inject:
- phone: "{{caller_phone}}"
- email: "{{caller_email}}"
- name: "{{contact.name}}"
The model fills one argument. The other three come straight out of saved state
and are hidden from it, so a digit cannot change between the turn it was agreed
on and the turn it was written down.
A variable can also carry confirm: verify_contact. Until that step hears a
yes, the value renders in no prompt but that step's own, and any tool injecting
it refuses itself and names the step to run first.
Speech through the gateway nearest your callers
models:
speak:
voice:
provider: slng
model: "deepgram/aura:2"
voice: "aura-2-thalia-en"
params:
world_part: eu-north
listen:
transcriber:
provider: slng
model: "deepgram/nova:3"
params:
world_part: eu-north
world_part picks one of 13 SLNG speech gateways, on each listen and speak
model separately. It becomes the host eu-north.api.slng.ai in the generated
project: slng_base_url= on LiveKit, base_url= on Pipecat.
The 13 are us-east, us-west, br, eu-west, eu-north, gb, za, il,jp, sg, id, in and au. Leave world_part out and the existing default
URL stands. Reasoning through the SLNG Context Router reads the same key and the
same 13 values, so one word covers listening, thinking and speaking. Where the
worker itself runs is a separate choice, deployment_region in targets.yaml,
and it does not have to match: salon-concierge deploys to eu-central and
speaks through eu-north.
SLNG deployment uses these same 13 region names. The location of each service
is still chosen separately.
Turn taking you can hear
models:
turn:
detector:
provider: local
model: silero
pace: patient
pace: is snappy, balanced or patient. It sets how long the agent waits
after the caller stops talking. A desk that asks people to spell an email
address out loud wants patient, because they pause between the characters.
What validate catches
unmute validate reads the package the way the compiler does, and names the
file, the line and often the column when something is wrong. It is offline and
needs no account. A few of the things it refuses:
- a
finish:whose success value the tool's own output cannot hold - a
skip_when_confirmed:naming a value that step does not confirm - a pre-fetch entry reading a value only a later entry assigns, naming both and
which one to move - a
type:outside the grammar, with the column inside the expression - a prompt placeholder naming a value the package does not declare, or a greeting
naming one that is not settled before the first word - a
world_partthat is not a gateway, listing the ones that are - a feature the chosen target cannot run, naming what to write instead
Warnings are the other half. A credential a package uses but never declares insecrets:, an agent holding every tool of its own step so the step's assign:
never runs, a turn field that reaches nothing: each one names the thing to
change, goes to standard error, and still exits 0.
What goes in a package
Each of these has a page in the guide.
| Where it is taught | |
|---|---|
| Tools that call a webhook, run local Python, reach an MCP server, use one the runtime already has, or search your own documents | Tools |
| Tasks, task groups and handoffs, for when one prompt stops being enough, and the steps that end on their own tool | Orchestration |
| Typed values, declared once and checked where they enter: phone numbers, dates, email addresses, a closed set of words, lists, and shapes of your own | Variables |
| Escalation to a person, cold or warm depending on the phone route | Transfers |
| Phone calls, inbound and outbound, through Twilio, SIP trunks or a carrier stream | Phone calls |
| Pre-fetch, so a known fact is in the prompt before the caller finishes the first sentence | Pre-fetch |
| Tracing to Langfuse or Coval, with per-turn latency and tool calls | Tracing |
| Turn taking, the context router, and regional infrastructure | Optimization |
| Secrets, so a credential never sits in a package file | Secrets |
| What to declare, and what to leave out, once a package has more than a handful of values | State design |
Every key of every package file is listed under
configuration.
Commands
| Command | What it does |
|---|---|
init |
scaffold a new package, or open an interactive console with no name |
validate |
check a package against its targets, naming the file and the line when a field is wrong |
compile |
write the generated project for every target |
dev |
compile, run locally, and talk to the agent in your browser |
deploy |
validate, compile and push a package to SLNG |
pull |
fetch each SLNG-hosted tool's definition into the package |
resources |
list the tools, MCP servers and phone numbers your SLNG organisation offers |
skill |
install the Unmute skill so a coding assistant can build packages |
validate, compile, dev, deploy and pull take the package directory as
an optional argument. From inside the package you run them bare; from anywhere
else you pass the path, as in unmute dev my-agent.
validate and compile work offline. SLNG deployment resolves hosted tools
by name with unmute deploy: no hash, mirror or pull step is needed. Only
LiveKit and Pipecat need unmute pull to fetch the hosted tools they run
themselves. deploy, pull and resources use your SLNG account.
Warnings go to standard error and still exit 0. Errors exit 1.
Coding agents
unmute skill install writes the Unmute skill into your repository, so Claude
Code, Cursor, Codex and the rest know how to author a package. The skill ships
inside the binary, so nothing is downloaded, and the files travel with the repo
so a team shares one skill. The docs are also readable asllms.txt andllms-full.txt, or one page at a
time by adding .md to its URL. See
Coding agents.
Templates
Ready-made agents live in their own repository:
slng-ai/unmute-templates.
Each folder there is one finished agent you can run, copy and change. The
repository README lists every template, what it shows, and which API keys it
needs.
git clone https://github.com/slng-ai/unmute-templates
cd unmute-templates
unmute validate single-prompt # check the files
unmute compile single-prompt # write the projects
unmute dev single-prompt --target pipecat # talk to it in your browser
Copy the folder you like, change the name: line in its agent.yaml, and it
is yours.
To add a template, open a pull request on
slng-ai/unmute-templates, not
here. A good one is one folder showing one idea, it validates and compiles, it
carries a README saying what to listen for and which keys it needs, it adds a
row to that repository's table, and it holds no secrets.
CONTRIBUTING.md is the long version.
Templates are agents to start from. Examples below are packages to read: one
per feature of the compiler, held to every gate in this repository.
Examples
examples/ holds four packages.
customer-intakeis the small one: one agent,
three tasks, one local tool. It takes a caller's number, name, email address,
enquiry and callback time, saves each under its own type, and hands them to a
tool the model cannot type over. Every type in the grammar appears once. Start
here if the question is about types, saved values or injection.salon-conciergeis the big one: two agents, four
tasks, a task group that skips verification once the number is agreed, three
steps that end on their own tool withfinish:, handoffs in both directions, a
cold manager transfer, tracing, and inbound phone on both code targets. Every
tool is local Python, so nothing remote has to be up before the greeting.salon-concierge-single-promptis
the same salon with the structural features taken back out, so the one above
can be read against something. Model, transport and turn taking are held
identical, so a difference you hear is a difference the structure made.hotel-conciergeis the hosted target's
showcase: a concierge line whose tools are all references SLNG already holds,
with template variables, an injected argument, a tool announcement, named MCP
tools and a fallback. It produces no runnable project:unmute deploycompiles a deployment body and pushes it.
If you want a package to start from rather than one to read, rununmute init my-agent.
Develop
make install # builds and puts unmute on your PATH
make test # go test -race ./... , pure Go, no Python needed
make lint
make fmt
make smoke proves the emitted Python is valid and needs Python installed, so it
is opt-in and never the pull request gate. make contracts re-fetches the
published SLNG conformance fixtures and needs network.
Contributing
Contributions are welcome, from anyone. Unmute is MIT licensed and every part of
it is open: the compiler, the three targets, the examples, the skill and the
docs site.
A pull request needs five things:
- An issue, opened before you write the code. Search the
open issues first, then open a
bug report, an improvement or a feature request. Link it from the pull
request. - An example package that uses your feature. Shaped like the ones in
examples/, with your new key in its authored files and the
code path running on a real call, so we can compile it, dial it and hear it. - A video of that example working.
- A README for that example, saying which part of it is your feature.
- Updated docs under
docs-site/that explain how the
feature works, not only what the key is called.
CONTRIBUTING.md is the whole thing: what the example has to
exercise, where the package goes, what a public example has to pass, and every
check CI runs with its local command.
Say hello on Discord before you start something
large. It is the fastest way to find out whether somebody is already on it.
Resources
- Documentation covers everything above in order.
- Templates are ready-made
agents to clone, run and copy. - How Unmute works is the
four compiler stages between your package and the generated project. - Configuration reference is
every key of every package file. - Changelog says what changed in each
release. docs/ARCHITECTURE.mdexplains the design and points
at the load-bearing code.- Issues for bugs and requests.
- Discord for questions, and for showing what
you built. - Contributing for how to send a change.
Unmute is MIT licensed. See LICENSE.
Reviews (0)
Sign in to leave a review.
Leave a reviewNo results found