Parlour

Tuning

Adjust wake sensitivity, timing, voice and models, and fix what feels wrong.

Everything on this page is a key in ~/.config/parlour/config.json. parlour config edit opens the file. parlour config show prints what is in force, with the defaults filled in. parlour restart (or a restart from the app) picks up your changes.

For the wake word, the voice, the microphone and the local model, running parlour init again is often quicker. Every question starts at what is set now, Escape goes back one question, and a review at the end lets you change any part before anything is downloaded or written.

When something is not quite right

What you noticeWhat to try
It wakes up for the televisionRaise wake.threshold towards 0.7.
It never wakes upLower wake.threshold towards 0.4, and check that audio.inputDevice is the microphone you think it is.
It cuts you off mid-sentenceRaise audio.silenceMs, or lower audio.silenceThreshold.
It waits too long before answeringLower audio.silenceMs to about 600.
It gives up before you start talkingYou have 2.5 s after the wake word to begin. Try saying the wake word and the request in one breath.
It answers slowlyTry a smaller local model (parlour models suggest lists them), or a smaller whisper model: parlour models fetch --whisper ggml-base.en.bin, then parlour service install to rewrite the whisper service. It loads the first model file in ~/Library/Caches/parlour/models/whisper/, and base sorts before small.
It asks the cloud too often, or not often enoughBy default the local model decides, guided by its prompt (packages/parlour/src/core/prompt.ts). For a threshold you can tune, add the decision model.
It says nothing at allRun parlour doctor, or press Check in the app. It names the part that is down.
You cannot interrupt itaudio.bargeIn lets the wake word cut a reply short. It is off by default, because with one box in one room the microphone hears the speaker.
It fires twice on one wake wordRaise wake.refractoryMs.
The voice is not the one you wantedSet tts.voice and tts.speed. Kokoro downloads itself the first time Parlour starts after an install, so that first start is slow and the fallback voice may speak until it is ready.
It can see the lights but not the blindsExpose the blinds. Home Assistant only publishes what is exposed under Settings > Voice assistants > Expose.

Audio

{
  "audio": {
    "inputDevice": ":0",
    "silenceMs": 800,
    "maxUtteranceMs": 15000,
    "silenceThreshold": 0.012,
    "bargeIn": false
  }
}
  • inputDevice is an avfoundation index, and ":0" is the default microphone. ffmpeg -f avfoundation -list_devices true -i "" prints the list, and parlour init shows it to you when it asks.
  • silenceMs is how much quiet ends a request once you have started talking. silenceThreshold is the RMS level, from 0 to 1, below which a frame counts as silence. Raise it for a noisy room. Lower it for a quiet speaker.
  • maxUtteranceMs caps a single request. It guards against a microphone that never goes quiet. It is not a way to allow long questions.

The wake word

{ "wake": { "provider": "openwakeword", "words": ["hey_jarvis"], "threshold": 0.5, "refractoryMs": 1500 } }

The stock words are hey_jarvis, alexa and hey_mycroft. parlour models fetch --wake hey_jarvis,alexa fetches the ones you name into ~/Library/Caches/parlour/models/openwakeword/. Any openWakeWord model works. Drop <word>.onnx into that directory and add <word> to words. You can have more than one word live at once, for a small cost in CPU. To train your own word, see Your own wake word.

The voice

{ "tts": { "provider": "kokoro", "voice": "bf_emma", "speed": 1.0, "fallback": "macos-say" } }

parlour init offers four British voices: bf_emma (the default), bf_isabella, bm_george and bm_lewis. Kokoro has more, among them bf_alice, bf_lily, bm_daniel and bm_fable, and American voices whose ids start af_ and am_, such as af_heart. If you name a voice Kokoro does not have, the reply falls through to the fallback and the log lists every voice Kokoro knows.

fallback names the voice used when the first one fails (null turns the fallback off). macos-say ignores a Kokoro voice id and uses the system voice. You can also give it a macOS voice name of its own ("voice": "Daniel" when provider is macos-say).

Speech to text

whisper.cpp is the default, kept warm as a service. On macOS 26 you can use Apple's own on-device recogniser instead, through the yap command line tool: nothing to download and no server to run.

brew install yap
{ "stt": { "provider": "yap", "locale": "en-GB" } }

locale is optional and defaults to the Mac's own. parlour init installs yap for you once the config names it. The whisper service stays installed until you remove it with parlour service uninstall. If launchd rather than the app runs the agent, follow it with parlour service install, which puts back only what the config still wants. parlour doctor checks yap is found.

On Apple silicon there is also NVIDIA's Parakeet, through parakeet-mlx. It is fast and accurate for English and uses about 2 GB of memory.

pip install parakeet-mlx
{ "stt": { "provider": "parakeet-mlx" } }

Parlour keeps the model loaded in a Python worker that starts on the first utterance, so that one waits for the load (and, the first time, the download into the Hugging Face cache). model defaults to mlx-community/parakeet-tdt-0.6b-v2; mlx-community/parakeet-tdt-0.6b-v3 trades a little English for about 25 languages. python defaults to python3; point it at a venv's own Python if parakeet-mlx lives there, for example ~/.local/share/uv/tools/parakeet-mlx/bin/python after uv tool install parakeet-mlx. parlour doctor checks that Python can import it and that ffmpeg is found.

The models

This is what parlour init writes when you let Parlour run the local model, on a Mac with 16 GB, with the defaults filled in:

{
  "llm": {
    "local": { "provider": "openai-compatible", "managed": true, "baseUrl": "http://127.0.0.1:8920/v1", "model": "qwen3.5-9b", "temperature": 0.3, "timeoutMs": 30000 },
    "cloud": { "provider": "anthropic", "enabled": true, "model": "claude-opus-5", "maxTokens": 1024, "onLocalFailure": true },
    "maxToolRounds": 6
  }
}

The catalogue Parlour can run, and how much memory it suggests each for:

Model idDownloadSuggested from
qwen3.5-4b2.7 GB8 GB
qwen3.5-9b5.7 GB16 GB
gemma-4-26b-a4b17 GB32 GB
qwen3.6-35b-a3b20.4 GB64 GB

An Intel Mac runs the model on the CPU, so it is counted as having half its memory and is suggested a smaller model.

  • local.managed means Parlour runs the model itself: llama.cpp from Homebrew and a GGUF from its own catalogue, kept warm by launchd on port 8920, just like whisper. At a terminal, init offers this first and suggests the largest model in the catalogue above that the Mac can hold. parlour models suggest prints the same list with this Mac's pick marked. To switch, run init again and pick another: it downloads the file and points the service at it. By hand, that is parlour models fetch --llm <id>, local.model set to the id, and parlour service install. For a script, parlour init --local-model auto|none|<id> answers the question without a terminal.
  • Without managed, Parlour talks to whatever server baseUrl names and starts none of its own. The provider's defaults are LM Studio's: http://127.0.0.1:1234/v1 and qwen3-8b-mlx. That is also what init --yes, or init with no terminal, writes on a fresh setup: a download of gigabytes is said yes to, never assumed.
  • local.model is the model id exactly as the server reports it. parlour doctor lists what is being served next to what is configured. A mismatch there is common after loading a different model in LM Studio. A managed server answers to the catalogue id, so the two only differ when the named model was never fetched.
  • local.timeoutMs applies to each completion. A model that often runs past it is too big for the machine. With cloud.onLocalFailure on, every timeout becomes a cloud answer, which is slower and not private.
  • cloud.enabled: false, or no ANTHROPIC_API_KEY, keeps everything local. The escalation tool is not offered, and the persona stops telling the model to hand over. The local model answers everything itself, with the house tools still available, including questions it probably should not.
  • maxToolRounds stops a model that keeps calling tools. Six is enough for "turn the kitchen and hall lights off and set a timer".
  • A hosted OpenAI-compatible endpoint also works as the local model. Add "apiKeyEnv": "SOME_NAME" naming a variable in secrets.env.

Through the AI SDK instead

You can fill either slot with "provider": "ai-sdk". It reaches the same two back ends through Vercel's AI SDK instead of Parlour's own clients:

{
  "llm": {
    "local": { "provider": "ai-sdk", "backend": "openai-compatible", "baseUrl": "http://127.0.0.1:1234/v1", "model": "qwen3-8b-mlx" },
    "cloud": { "provider": "ai-sdk", "backend": "anthropic", "model": "claude-opus-5", "effort": "low", "webSearch": true }
  }
}

The loop does not move. Tools are handed to the SDK without an execute, so the SDK returns the calls instead of running them. The tool rounds, the escalation and the request deadline all stay in Parlour. The only change is who keeps up with the two APIs.

Parlour only runs its own model server for the openai-compatible provider, so an ai-sdk local slot needs a server of its own, such as LM Studio.

The built-in clients remain the default, because they need nothing but fetch. Choose the AI SDK if you would rather track one library than two APIs, or want to put the SDK's own middleware in front of a model.

Decision model (optional)

{
  "llm": {
    "decision": {
      "provider": "laya-mlx",
      "mode": "shadow",
      "escalateThreshold": 0.85,
      "localConfidence": 0.75,
      "model": "aac6fef/laya-mlx"
    }
  }
}

Laya MLX scores each task on your Mac and decides whether it needs the cloud. It runs after triage has split the request and before the local model takes its turn. It needs Apple silicon and pip install laya-mlx. The first load downloads the checkpoint into the Hugging Face cache.

  • Start with "mode": "shadow". The log shows needs_cloud, the intent and the confidence, but ask_the_clever_one still decides.
  • Switch to "mode": "triage" once the scores look right. Scores above escalateThreshold go straight to the cloud model. A clear house or timer intent keeps escalation off, so house commands stay local.
  • "provider": "none", the default, skips the decision model entirely.
  • Multilingual house? Set "model": "aac6fef/laya-multilingual-mlx".

The pipeline

{
  "pipeline": {
    "concurrency": 2,
    "queueDepth": 2,
    "triage": "auto",
    "maxTasks": 4,
    "timeoutMs": 45000
  }
}
  • concurrency is how many requests are answered at once across the whole house. Each client gets one request at a time either way, so this sets how many rooms can be answered at once, not how hard one room can push. Two suits one machine with one local model. Raise it if the model is hosted elsewhere and can take the load. The queue still keeps each room in turn.
  • queueDepth is how many requests may wait in one client's lane. Past that, the client's oldest waiting request is dropped silently. Someone who asks twice wants the second answer.
  • triage controls when a request is repaired and split before Parlour acts on it. auto reads anything compound, long or ambiguous, and lets a plain instruction straight through. always costs a local round trip on every request, which is worth it in a noisy room or with a small speech model. never sends the words to the action agent exactly as heard.
  • maxTasks caps how many tasks one request may become. Four covers "turn the kitchen and hall lights off, set a timer and tell me the forecast".
  • timeoutMs is the ceiling on one whole request. It is checked between tool rounds and between tasks, not mid-request, so nothing already with a model is abandoned. It stops a request that is still running long after the person has left the room.

You can change these from a client too: parlour remote pipeline set or the iPhone app's Server tab, within tighter limits than the file allows. See Managing the server.

Keeping the Mac responsive

The local model and whisper share the Mac with whoever is using it. On Apple silicon they also share its memory with the GPU. A model that does not fit does not fail cleanly: the whole machine slows to a crawl. So Parlour holds them back:

  • A model whose weights would take more than 70% of the Mac's memory is not started, and parlour doctor says so. parlour models suggest names one that fits.
  • Both servers leave two cores free, and use at most six. The local model keeps one conversation's worth of context rather than several.
  • Both run at a lower priority than anything you are using. launchd waits a minute before starting a crashed one again, rather than ten seconds.
  • Inside the App Store app, where Parlour keeps them running itself, a server that crashes five times in ten minutes is left stopped until you start it from a client.
{ "search": { "provider": "searxng", "url": "http://searxng.local:8080", "maxResults": 5 } }

Only the local model uses this. The cloud model brings its own search. SearXNG needs - json under search.formats in its settings.yml. For a hosted alternative, use "provider": "brave" with BRAVE_API_KEY in secrets.env. "provider": "none" leaves the local model without a search tool.

Logging

Set LOG_LEVEL in secrets.env (or the environment) to debug, info, warn or error. debug prints every transcription time and every tool call with its arguments. Use it when you want to know why the model did what it did. Service logs go to ~/Library/Logs/parlour/agent.log, whisper.log and, when Parlour runs the local model, llm.log. parlour service logs --lines 200 prints the tail of each.

On this page