Parlour

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 restart

Without 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 token

That 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 }
}
  • serverUrl empty means "find it with Bonjour, once". parlour init looks 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. Run parlour doctor on the satellite to see whether it can find a server, and whether macOS is letting it onto the local network at all.
  • localWake runs 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 fetch puts 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 pair

It 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 serverMeaning
{"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 reaches audio.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.

RouteWhat it does
GET /adminThe services, the pipeline in force and the one saved, the memory, and whether the agent comes back by itself after a restart.
POST /admin/pipelineSaves 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/doctorThe server's own checks, parlour doctor over the network.
GET /admin/logs?service=agent&lines=50The end of one log: agent, llm or whisper, up to 200 lines.
POST /admin/restartRestarts 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

On this page