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/bundleLive 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.
- Install. One button installs
parlourfrom npm if it is missing, then runsparlour init --porcelain --yesfor 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. - Voice. Allow the microphone, choose the wake word, and optionally a different microphone.
- Home. The Home Assistant address and token. Skip it and timers, questions and search still work.
- Extras. Cloud help (the Anthropic key) and letting other devices in with an access token, each behind its own switch.
- 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.
| Tab | What you will find there |
|---|---|
| Status | The 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. |
| Connectors | What your household has signed in to, and a form to add another. parlour connectors, with buttons. |
| Settings | Where 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. |
| Logs | Parlour'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 itApart 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 initasks which you want and sets up only that one. When the app runsinit, it leaves the agent to the app, and warns if a LaunchAgent is already installed.parlour service statustells you whether a LaunchAgent exists, andparlour service uninstallremoves 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
NSMicrophoneUsageDescriptionthe 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
NSLocalNetworkUsageDescriptionfor 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, andpnpm exec nx run design:iconswritessrc-tauri/icon.pngand the menu bar'ssrc-tauri/tray.pngfrom it (see the design system).pnpm iconsderives every other size intosrc-tauri/icons/, which is not in git, and bothappandbuildrun it first. The source sits outside that directory on purpose.tauri iconwrites anicon.pnginto its output, so a source kept there would overwrite itself on every build and leave the working tree dirty.