Parlour

The menu bar app

What the app does, how it drives the CLI, and how to run it from a checkout.

The menu bar app handles the everyday things without a terminal: start, stop, is it listening, what did it just hear, why is it not answering.

It is called Parlour Server, to tell it apart from Hey Parlour, the iPhone app that talks to it.

apps/desktop is a Tauri app, a Rust shell around the parlour command with a window as its only interface. It is not a second implementation. It learns everything by running the CLI, and writes every setting through parlour config write, so the app and the terminal always see the same configuration.

Building it

You need Node 22, pnpm and a Rust toolchain (rustup).

pnpm install
pnpm -C apps/desktop app        # the window from the checkout, with live reload
pnpm -C apps/desktop build      # Parlour Server.app and a dmg, in apps/desktop/src-tauri/target/release/bundle

Live reload covers the window. The CLI it drives is whichever parlour the app's settings point at. On a fresh machine that is the globally installed one, not the checkout. See Running the app against a checkout.

A build from your checkout is unsigned, which is fine on the machine that made it. Copy Parlour Server.app into /Applications and right-click to open it the first time. A release dmg is signed with a Developer ID certificate and notarised, so it opens normally on any Mac with Apple silicon, which is what it is built for. Each release is on GitHub, and the newest dmg is always at releases/latest/download/Parlour-Server-arm64.dmg.

The app does not bundle Node or the package. It looks for parlour on your login shell's PATH, so nvm, fnm and volta installs are found, then in /opt/homebrew/bin, /usr/local/bin and ~/.npm-global/bin. If it cannot find one, it offers to run npm install -g parlour at its own version, which needs Node 22 or newer. One copy of Parlour on the machine is better than two that can disagree.

Signing

Notarising requires the hardened runtime, which blocks the microphone unless the app asks for it. src-tauri/Entitlements.plist asks, and Info.plist next to it holds the sentence macOS shows the user. ffmpeg does the recording, but macOS attributes the request to the app that started it, so both files belong to the app.

The desktop release workflow sets the Apple environment variables Tauri reads. Tauri creates its own keychain for the certificate, signs the app, notarises Parlour Server.app and staples the ticket into it. It signs the dmg but does not notarise it, and macOS refuses a disk image without its own ticket. So scripts/notarise.sh, at the root of the repository, notarises and staples the dmg, then asks Gatekeeper to assess both and checks the app's signature carries the hardened runtime and the microphone entitlement. Run on a machine with no Apple credentials, it says so and stops.

.github/workflows/desktop-release.yml lists the six repository secrets behind it and what each one is.

Setting up

On a machine that is not set up yet, the app opens on setup instead of the tabs, and walks you through it one step at a time. Anything optional stays folded away until you ask for it. Run setup again in Settings brings it back later, skipping the welcome.

  1. Install. One button installs parlour from npm if it is missing, then runs parlour init --porcelain --yes for the rest (ffmpeg and whisper from Homebrew, the models, the config). The log stays folded away unless you ask for it or something fails. Already have parlour somewhere else? takes a path instead.
  2. Voice. Allow the microphone, choose the wake word, and optionally a different microphone.
  3. Home. The Home Assistant address and token. Skip it and timers, questions and search still work.
  4. Extras. Cloud help (the Anthropic key) and letting other devices in with an access token, each behind its own switch.
  5. Finish. The doctor's checks, and a button to start listening.

The tabs

The header carries a dot that follows Parlour through idle, listening, thinking and speaking, and a Start or Stop button.

TabWhat you will find there
StatusThe last thing it heard, the last thing it said and which model said it. How many tools it has and whether cloud escalation is on. The address phones and Home Assistant should use, and Pair a phone, which shows the QR code the iOS app scans. A Check button that runs parlour doctor --json.
ConnectorsWhat your household has signed in to, and a form to add another. parlour connectors, with buttons.
SettingsWhere parlour is, whether to start listening when the app opens, wake word, sensitivity, microphone, silence timeout, voice, the local and cloud models, the Home Assistant address and the secrets, including the access token. It reads parlour config show --raw (the file as written, so saving does not freeze every default into it) and writes through parlour config write and parlour secrets set.
LogsParlour's output, live.

The tray menu has Open, Start listening, Stop and Quit. Quit asks first, because it stops more than the app. The agent and the services it keeps running (whisper and the local model) are stopped too, so they do not keep holding memory for a server nobody can reach. They start again when you open the app, or at the next login if they are installed as services. When a client restarts the agent to apply a setting, the app starts it again itself, as launchd would.

How it fits together

src/
  App.tsx           the header, the tabs, and the state the panels read
  panels/           one file per tab, plus the setup overlay and its parts in onboarding/
  lib/bridge.ts     every Rust command, typed. Nothing else calls invoke()
  components/ui/    shadcn primitives
  |  invoke()
src-tauri/src/
  main.rs           the tray, the window, one generic parlour(args, stdin) command, the mic check
  supervisor.rs     parlour start --events as a child process, and its NDJSON events
  setup.rs          parlour init --porcelain, and npm install -g parlour, streamed
  settings.rs       the app's own two facts: where parlour is, and whether to start it

Apart from running ffmpeg to list microphones and raise the permission prompt, the Rust side knows the path to the parlour binary and nothing else. The window asks for config, secrets, the doctor and the connectors through the CLI's --json and stdin interfaces. The supervisor reads the NDJSON events that parlour start --events prints (ready, state, heard, reply, muted, error) instead of scraping log text. Anything else it prints is log output, and that is where a failure to start shows up.

Stopping sends SIGINT, not SIGKILL. Parlour shuts ffmpeg down on SIGINT, and an orphaned ffmpeg would keep hold of the microphone.

The app's own settings live at ~/Library/Application Support/io.parlour.desktop/settings.json: { "parlourBin": "/opt/homebrew/bin/parlour", "autostart": false }.

Running the app against a checkout

pnpm -C apps/desktop app reloads the window from the checkout, but every CLI call goes to the parlourBin in settings.json. The app fills that in by asking your login shell for parlour. So the app does not exercise your changes to packages/parlour until you point it there.

packages/parlour/bin/parlour-dev runs the CLI from source. Point the app at it in either of two ways:

  • Put its absolute path in The parlour command on the Settings tab (or under Already have parlour somewhere else? on the Install step of setup). This takes effect at once.
  • Write it to the file and restart the app, which reads the file once at launch:
{ "parlourBin": "/path/to/parlour/packages/parlour/bin/parlour-dev", "autostart": false }

The app keeps the path while it is executable and will not swap in the global copy. The dev build and the installed Parlour Server.app share this settings file, so when you are done, put the global path back, or delete the file and let the app find parlour again.

Three things to know

  • Use the app or the LaunchAgent, not both. Two copies of Parlour means two processes fighting over one microphone and one port. parlour init asks which you want and sets up only that one. When the app runs init, it leaves the agent to the app, and warns if a LaunchAgent is already installed. parlour service status tells you whether a LaunchAgent exists, and parlour service uninstall removes it.
  • Microphone permission belongs to whatever starts Parlour. macOS grants it to the process it holds responsible, which for anything the app spawns is the app. Setup opens the device for a moment as early as it can, which brings up the system prompt. The bundle carries the NSMicrophoneUsageDescription the prompt needs. Without it, macOS refuses instead of asking. The grant is tied to the bundle identifier, so it survives rebuilds, and it covers Parlour as the app's child. A LaunchAgent needs its own grant and asks the first time it runs.
  • So does the local network. Since macOS 15, a process has to be allowed onto the local network before phones, satellites and Home Assistant can find or reach it, and until then Bonjour fails with "no route to host". The doctor sends one Bonjour question, which brings up the prompt during setup rather than when the phone first looks, and fails with the way to the switch if it was refused: System Settings, Privacy & Security, Local Network. The bundle carries NSLocalNetworkUsageDescription for it. The Mac has no multicast entitlement to add: Apple's is for iOS, and on macOS the permission is the whole story.

Deliberate choices

  • No Tauri plugins. Everything the app does happens in Rust, where it is easy to reason about. The window's capability list is just core:default.
  • A true menu bar app. The activation policy is Accessory, so there is no Dock icon and no menu bar of its own. The tray is the way back to the window. Closing the window leaves Parlour running. Quitting from the tray asks, then stops the agent and everything it keeps warm.
  • Generated icons. The mark is drawn once, in packages/design, and pnpm exec nx run design:icons writes src-tauri/icon.png and the menu bar's src-tauri/tray.png from it (see the design system). pnpm icons derives every other size into src-tauri/icons/, which is not in git, and both app and build run it first. The source sits outside that directory on purpose. tauri icon writes an icon.png into its output, so a source kept there would overwrite itself on every build and leave the working tree dirty.

On this page