Clients
Connect satellites, phones, custom hardware and scripts to your server.
One machine in your house runs the models, holds the tokens and answers. Everything else with a microphone is a client. It sends what it hears, plays back what it is sent, and runs no models of its own. You never need to give a client an address. The server advertises itself with Bonjour and the clients find it.
finds it with Bonjour
kitchen satellite ->\ _parlour._tcp
study satellite ->\
Voice PE, via HA -> the server -> tools, models, connectors, the house
phone, push to talk ->/
custom hardware ->/
its own microphone ->/The server's own microphone is just another client, with no special privileges. Every client keeps its own conversation, so a follow-up in the kitchen cannot pick up something asked in the study. Every client can also say which room it is in.
The token
Every client needs PARLOUR_TOKEN. parlour init offers to generate one for
you, or you can set it by hand with parlour secrets set PARLOUR_TOKEN. It
reads the value from stdin, so it never ends up in your shell history:
openssl rand -hex 24 | parlour secrets set PARLOUR_TOKEN
parlour service restartWithout a token, the server listens only on loopback, answers only the machine
it runs on, and does not advertise itself. An open voice assistant can turn on the
heating and read your shopping list, so closed is the safe default. A
browser on that same machine is held to it too: a request addressed to
anything but a loopback name (localhost, 127.0.0.1, ::1), or sent by a
page that was not served from one, is refused. parlour doctor tells you
which state you are in.
Clients send the token as Authorization: Bearer <token>, or as ?token= on
the URL, which is how the socket takes it. /health needs no token and
reports what is configured and what is in flight:
{ "ok": true, "tools": 14, "cloud": true, "running": 0, "waiting": 0 }.
A satellite
Any Mac with a microphone can be a satellite. All it needs is Node, ffmpeg and
the parlour package. No models, no keys, no GPU, nothing to keep warm.
npm install -g parlour
parlour init # answer "satellite", name the room, paste the tokenThat writes role: "satellite" and installs the service (a satellite has no
app to own it). From then on, it finds the server by name and reconnects for
as long as it is switched on. While the server is down, it waits longer
between attempts, doubling up to satellite.retryMs.
{
"role": "satellite",
"satellite": { "room": "kitchen", "serverUrl": "", "localWake": false, "retryMs": 15000 }
}serverUrlempty means "find it with Bonjour, once".parlour initlooks while you watch. A satellite that starts with the key empty keeps looking until a server lets it in. Either way, the address it finds is written here, and the satellite talks only to that server from then on. This is deliberate. Anything on the network can advertise_parlour._tcp, and the satellite hands its token to whatever it connects to, so the first server it trusts is the only one it trusts. If your server really moves, clear the key. If the pinned server is down and something else is advertising, the log says so and the satellite stays put. You can also fill in the key by hand (http://study-mac.local:8765) if your network drops multicast, as some mesh systems and most guest VLANs do. Runparlour doctoron the satellite to see whether it can find a server, and whether macOS is letting it onto the local network at all.localWakeruns the wake word on the satellite and streams only what follows it. It saves a constant 32 KB/s on the network, but needs a copy of the models on that machine (parlour models fetchputs them there). It is off by default, so there is one less copy of the models to keep up to date.
The satellite plays the server's speech, so the voice is the same in every room and you change it in one place.
Home Assistant satellites
This is the cheapest client, because the hardware is already in your house.
Home Assistant keeps handling the wake word, speech to text and the spoken
reply. Only the thinking moves to Parlour. The full setup is in
Home Assistant. In short, point the OpenAI
Conversation integration at http://<the server>:8765/v1, with model
parlour and your token as the API key.
The iPhone app
The Parlour app talks to /health, /voice and /ask on the server. Rather
than typing the address and a 48 character token on a phone, run this on the
server:
parlour pairIt draws a QR code in the terminal. In the app, open Settings and tap Scan
pairing code, or point the Camera at it; either way the app takes the address
and the token together and checks the server there and then. The desktop app
shows the same code under On the network, behind Pair a phone. The code
carries the token, so show it only to phones you mean to let in. It uses the
Mac's .local name; if the phone cannot reach the Mac by that name, give it an
address it can: parlour pair --host 192.168.1.20.
parlour pair runs on the server only, and refuses until PARLOUR_TOKEN is
set, because without one a phone could not get in anyway.
A phone
Open http://<the server>:8765 and add it to your home screen. Hold the
button, say something, let go. The page's settings take the token and,
optionally, a room. A link with ?token=...&room=kitchen fills in both, so you
do not have to type them on a phone keyboard.
Safari only grants microphone access over HTTPS or on localhost. On iOS, put a reverse proxy with a certificate, or Tailscale, in front of the server.
Under the hood, the page posts one recording to
POST /voice?client=<id>&room=kitchen and gets back
{ heard, reply, via, audio }, where audio is a base64 WAV of the reply, or
null when nothing was heard. Each phone makes up its own client id, so each
keeps its own conversation; leave it out and the client is phone. Anything
that can record and post can use the same route.
Custom hardware
ws://<the server>:8765/listen?client=hallway&room=hallway&token=...Send 16 kHz mono signed 16-bit PCM as binary frames of any size. The server re-cuts them into the 80 ms frames the wake word needs. It replies with JSON events and one binary WAV per answer.
| From the server | Meaning |
|---|---|
{"type":"ready","sampleRate":16000,"frameSamples":1280,"mode":"wake"} | Connected. |
{"type":"state","value":"listening"} | Also thinking (with "text", what was heard), speaking and idle. |
{"type":"reply","text":"...","via":"local"} | Followed by the WAV as a binary frame. |
{"type":"stop"} | Stop playing: a new wake word arrived. |
- Wake mode, the default. The server runs the same openWakeWord and endpointing as its own microphone, so your device can be just a microphone, a speaker and a network stack.
- Push mode (
&mode=push). Your device marks the start and end of a request with{"type":"start"}and{"type":"end"}, or sends{"type":"cancel"}to throw it away. Use it for a button, or for hardware that does its own wake word. A request that never ends is answered once it reachesaudio.maxUtteranceMs.
Send {"type":"spoke"} when playback finishes, and the server starts
listening again straight away instead of waiting out its own estimate.
client names the conversation, so a device that reconnects picks up where it
left off. Two devices connected under the same name at once are told apart
(hallway~2) rather than made to share one.
Automations and scripts
curl -s http://<the server>:8765/ask \
-H "authorization: Bearer $PARLOUR_TOKEN" \
-H "content-type: application/json" \
-d '{"text": "is the washing machine finished", "room": "kitchen"}'The content type is required. A form on a web page can post text/plain
anywhere without the browser asking first, but it cannot claim to be JSON.
You get back {"reply": "...", "via": "local"}, where via says which model
answered. Add a client to the body for a conversation of your own.
Otherwise, every caller shares the api session.
Managing the server
A client with the token can also manage the server: change the pipeline's
settings, start, stop and restart the model servers it keeps warm, and run
the maintenance commands. The iPhone app's Server tab and parlour remote
both use these routes. Set server.admin: false to turn them off.
| Route | What it does |
|---|---|
GET /admin | The services, the pipeline in force and the one saved, the memory, and whether the agent comes back by itself after a restart. |
POST /admin/pipeline | Saves a change to any of concurrency, queueDepth, triage, maxTasks and timeoutMs, then restarts the agent so it takes effect. Add ?apply=false to save it for later. |
POST /admin/service | {"service": "llm" | "whisper", "action": "start" | "stop" | "restart"}. |
POST /admin/doctor | The server's own checks, parlour doctor over the network. |
GET /admin/logs?service=agent&lines=50 | The end of one log: agent, llm or whisper, up to 200 lines. |
POST /admin/restart | Restarts the agent. |
Some limits are there to protect the Mac, so the server can refuse a request
even with the right token. It answers {"error": "..."} with a sentence
saying why:
- The pipeline's numbers have tighter bounds than the config file allows. For example, at most four requests at once, and a timeout between 5 seconds and 5 minutes. Every request running at once is another generation the local model has to hold in memory.
- A model whose weights would take more than 70% of the Mac's memory is not started. The doctor warns you above 50%.
- The server takes one change at a time. It also refuses to repeat the same action on the same thing within 15 seconds, so a script stuck in a loop cannot restart the model over and over.
- The agent restarts by exiting and letting launchd, or the menu bar app, start it again. If you started it in a terminal, nothing would bring it back, so a restart is refused and a pipeline change is saved until the next start.
Bonjour
The server advertises _parlour._tcp with a TXT record of role=server,
version=1, token=required and api=/v1. A server without a token does
not advertise at all. The name is
discovery.name, or "<config.name> on <hostname>" when that is empty. Set
discovery.enabled: false to turn it off. Every satellite then needs
serverUrl filled in.
dns-sd -B _parlour._tcp # what is advertising, from any Mac on the network