# Documentation (/docs) A Mac you can describe in one file and rebuild from it on any machine you own, and the small native tools that live inside it. Two kinds of thing are documented here: **the layer**, and **the apps**. **haus** is the machinery; the rest are things that run on it, and run perfectly well on a Mac that has never heard of it. **pounce** and **perch** install from Homebrew with no Nix anywhere, and haus can install either for you instead. **trill** installs from nowhere yet: it is in the incubator, and its page says so before it says anything else. The switcher at the top of the sidebar moves between the trees, and its glyph tells you which one you're reading at a glance: the layer, the launcher, the shelf, the compositor. Looking for a **desktop** (hacker and the rest)? A desktop is a set of values for haus's own options rather than a project of its own, so it is documented with the layer: [choosing one](/docs/haus/desktops/choosing) compares the four, and each has a page of its own: [hacker](/docs/haus/desktops/hacker), [everyday](/docs/haus/desktops/everyday), [minimal](/docs/haus/desktops/minimal). Reading this with an AI tool? These docs publish themselves in machine-readable form: [`/llms.txt`](/llms.txt) is a table of contents with one-line descriptions, for something that fetches selectively, and [`/llms-full.txt`](/llms-full.txt) is every page inlined, for something that would rather read the lot. On a machine that already has haus, prefer the `haus` skill; it is generated from the revision you actually run. [Changing your Mac with an agent](/docs/haus/rooms/agent-rebuilds) covers it. # What haus is (/docs/haus) **haus** is a layer for macOS. You describe the machine you want in one text file: apps, window management, the bar, the shell, the colours, the macOS settings you always change by hand. Then one command makes the Mac match it. ```nix haus.theme.accent = "sapphire"; haus.hearth.editorName = "neovim"; haus.roster.slack = { key = "s"; name = "Slack"; cask = "slack"; }; ``` ```sh haus rebuild ``` Slack is installed and bound to a launcher key, your terminal is themed, and the change is a line in git you can read six months later. That is the whole idea. The rest of these pages are the detail. ## Why bother [#why-bother] * **It survives the machine.** A new Mac, or a wiped one, is one command away from being the old one, because the old one was text. * **It undoes.** A failed build never activates. A regretted change is `haus rollback`, atomic, in seconds. * **It's one setting, not forty.** The accent colour lands in the terminal, the file manager, git, the bar and the browser at once, because they all read the same value. * **Nothing is buried.** Every knob is a documented option with a default, and `haus edit` opens the file that sets them. You do **not** need to know [Nix](https://nixos.org) going in. It is what makes this reproducible, and the installer sets it up for you. You will pick some of it up over time, and that's the whole curve. ## What's in the box [#whats-in-the-box] haus is organised into twelve **rooms**, each one a capability you can turn on, off, or leave alone. Turn on the ones you want; the rest cost you nothing. | Room | What it does | | ----------------------------------------------------- | ------------------------------------------------------------------------- | | **[Apps](/docs/haus/rooms/apps)** | One list holds every app, font and CLI tool, and where each installs from | | **[Appearance](/docs/haus/rooms/appearance)** | The palette, the accent, the wallpaper, fonts and interface scale | | **[Displays](/docs/haus/rooms/displays)** | Per-screen scaling, said as an intent rather than a pixel count | | **[Development](/docs/haus/rooms/development)** | Ghostty, zellij, zsh, the editor, Git tooling and language runtimes | | **[Windows](/docs/haus/rooms/windows)** | Tiling, workspaces, and a launcher on a tap of Caps-Lock | | **[Bar](/docs/haus/rooms/bar)** | A status bar: workspaces, weather, media, battery, clock | | **[Launcher](/docs/haus/rooms/launcher)** | A ⌘Space command palette where every command is a file | | **[Shelf](/docs/haus/rooms/shelf)** | A file shelf that drops out of the notch to catch drag-drops | | **[Focus](/docs/haus/rooms/focus)** | One quiet switch: Do Not Disturb, your status, your hooks | | **[AI](/docs/haus/rooms/ai)** | Coding agents, each with its own checkout of the repo | | **[Text expansion](/docs/haus/rooms/text-expansion)** | Type a short trigger, get the long thing, in any app | | **[Security](/docs/haus/rooms/security)** | Touch ID for `sudo`, lock behaviour, firewall, secrets | Underneath them is the foundation nobody switches off: the macOS defaults, the Homebrew policy and the `haus` CLI itself. The [full reference](/docs/haus/reference/options) lists every option in every room, with its type and default. Which rooms are on is your **desktop's** decision, and disagreeing with it is one line in your host file. Each piece also stands alone: you can take the whole thing, or just [pounce](/docs/pounce), or just the palette. ## The shape of a haus machine [#the-shape-of-a-haus-machine] Your config lives at `~/.config/nix` and it is **yours**: your name, your keys, your app list. It imports haus; it never edits it. haus stays upstream, pinned to an exact commit, and nothing about your machine changes until you run `haus update`. That split is the whole design. Your identity is never in someone else's repo, and their improvements are never a surprise on your machine. ## Where to go next [#where-to-go-next] # Start blank (/docs/haus/desktops/blank) **Blank is a desktop that selects nothing.** It is the from-scratch choice, and it exists so that "I'll build my own" stays inside the one-desktop model instead of being a second mode with its own rules. ```nix darwinConfigurations.lovelace = haus.mkHaus { username = "ada"; hostname = "lovelace"; host = ./hosts/lovelace; desktop = haus.desktops.blank; }; ``` The whole file, upstream, is this: ```nix { haus = { }; } ``` Everything else is the room catalogue's own neutral defaults doing the work. ## What you get [#what-you-get] The foundation, and only that: * **the `haus` CLI**: `rebuild`, `plan`, `diff`, `set`, `rollback`, `update`; * **the app roster**: the one list that drives what's installed and from where, ready for entries you add; * **the Nix and rebuild plumbing**: pinned inputs, generations, garbage collection; * **haus's safe macOS defaults**: the conservative ones the foundation writes on every machine, and nearly all of them soft, so your host outranks them with a plain assignment. ([Two menu-bar keys are not](/docs/haus/desktops/customizing#beating-a-default-haus-set), and say so.) ## What you don't [#what-you-dont] No optional room is on: no bar, no tiling, no launcher, no shelf, no Focus switch, no coding agents, no developer toolbelt, no Touch ID for `sudo`. No global hotkey is claimed: Caps Lock stays Caps Lock and ⌘Space stays Spotlight's. The wallpaper is left alone, the tour doesn't run, and no theme files are written into apps you already had. That last one is the point of Blank rather than a stripped hacker: nothing here reaches outside haus and changes something you didn't ask for. ## Adding a room [#adding-a-room] Every room is one switch in your host file at `~/.config/nix/hosts//default.nix`: ```nix haus.sill.enable = true; # the bar haus.pounce.enable = true; # the launcher haus.keys.palette = "cmd-space"; ``` ```sh haus rebuild ``` A room enabled is a room that works: each one ships a neutral, useful configuration, so you get a drawn bar and a working launcher without tuning anything. Tune it afterwards from that room's page, or leave it. Turning on the Bar doesn't bring the launcher, and turning on Development doesn't bring coding agents. Where two rooms cooperate (agent pills on the bar, agent commands in the launcher), the extra appears only when *both* are on, and its absence never disables the room that offered it. ## Growing out of blank [#growing-out-of-blank] If you find yourself turning on most of the catalogue, you are describing a desktop that already exists. Try [selecting one](/docs/haus/desktops/choosing) and overriding the two or three things you disagree with. If what you have is genuinely yours, and you want it on a second Mac or in someone else's hands, that's a [desktop of your own](/docs/haus/desktops/creating). # Choose a desktop (/docs/haus/desktops/choosing) **haus supplies rooms. A desktop curates them. Your host file makes one yours.** A **room** is one capability (the bar, the launcher, window management) with its own options and its own switch. A **desktop** is a complete answer to *what should this Mac feel like?*: which rooms are on, and what the visible choices are. Your **host file** is where your name, your keys and your own overrides live. ```text haus foundation the rooms, their options, safe defaults ↓ one desktop hacker, everyday, minimal, blank, or yours ↓ your host file identity, secrets, hardware, and any override ↓ `haus set` what the palette and your agent write for you ``` Later layers win, deliberately. Anything a desktop chooses, you change with a plain assignment in your host file: no `lib.mkForce` anywhere. ## Exactly one [#exactly-one] A Mac runs **one** desktop. They don't stack, and the build says so: selecting two is refused whether they disagree or not, and so is selecting the same one twice, with a message of its own. Choosing again means changing one line, not unpicking a pile. If nothing you can name fits, that's what [Start blank](/docs/haus/desktops/blank) is for: a desktop that selects nothing, so "build my own" is still one desktop rather than a second mode. ## The four that ship [#the-four-that-ship] | Desktop | For | Rooms it turns on | | -------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | **[hacker](/docs/haus/desktops/hacker)** | the full thing: keyboard-first, developer, tiled | Development, AI, Windows, Bar, Launcher, Shelf, Focus, Security, plus the theme and generated wallpaper | | **[everyday](/docs/haus/desktops/everyday)** | a Mac for someone who doesn't write code | Launcher, Bar, Shelf, Focus, Security, the theme and the generated wallpaper; no tiling, no leader key, no coding tools | | **[minimal](/docs/haus/desktops/minimal)** | just the themed shell, on otherwise stock macOS | Development, Security and the palette. No bar, no tiling, no launcher, no wallpaper | | **[blank](/docs/haus/desktops/blank)** | build your own from the foundation | none | `everyday` deliberately leaves **Windows** off: tiling is good, but remapping Caps Lock on the Mac of someone who didn't ask for it is not. `minimal` is still a *developer* machine: few rooms, not few tools. A Mac with no developer tooling is `everyday`. ## Selecting one [#selecting-one] Your `~/.config/nix/flake.nix` names it in one line: ```nix darwinConfigurations.lovelace = haus.mkHaus { username = "ada"; hostname = "lovelace"; host = ./hosts/lovelace; desktop = haus.desktops.everyday; }; ``` Then apply it: ```sh haus rebuild ``` Leave the `desktop` line out and you get **hacker**, which is what every machine installed before desktops existed is still running. The installer writes the line for you; `HAUS_DESKTOP=minimal` picks a different one on an [unattended install](/docs/haus/install#unattended-installs). Vendor the `.nix` file into your config and point `desktop` at the path: `desktop = ./desktops/writer.nix;`. There is no separate import mechanism for someone else's desktop; that's the point of the format being closed. ## Changing your mind [#changing-your-mind] Switch the line, rebuild, and if you don't like it, `haus rollback`. Nothing about a desktop is one-way: it sets options, and options are what a rebuild computes from scratch every time. What a switch does **not** touch is anything outside haus: files you made, apps you installed by hand, and the macOS settings haus never claimed. Turning a room off removes what that room installed; it doesn't tidy up after you. ## Where to go next [#where-to-go-next] # Create a desktop (/docs/haus/desktops/creating) A **desktop** is a single `.nix` file whose only top-level key is `haus`. That is the entire format, and the smallness is the point: a desktop can't run a script, reach a macOS setting haus doesn't already expose, or hand the build a package of its own. A stranger can read one in a minute and know the worst it can do, which is what makes running one from someone you've never met reasonable. haus ships four: [hacker, everyday, minimal and blank](/docs/haus/desktops/choosing#the-four-that-ship). This page is how you write yours. ## The format [#the-format] ```nix { haus = { developer.enable = false; prowl.enable = false; keys.leader = "none"; theme = { flavor = "latte"; accent = "rosewater"; }; sill.items.weather = false; }; } ``` There is no `{ pkgs, lib, config, ... }:` header; this is data, not a module function. No `system.defaults`, no `pkgs`, no activation hooks, no `imports`. Everything stays inside the documented [`haus.*` option surface](/docs/haus/reference/options), and only the leaves marked safe for desktop data. Someone selects it the same way they select one of haus's own: ```nix desktop = ./desktops/writer.nix; ``` ## A desktop is the whole answer [#a-desktop-is-the-whole-answer] A host runs **exactly one**, so yours is not a diff against hacker; it is the complete selection. Whatever you don't say, the rooms' own neutral defaults decide, which for every optional room means **off**. That is the one thing to check twice when you port something that used to be a layer stacked on another desktop: the values it silently inherited are now values it has to state. haus's own `everyday` is the worked example: it names `collar.passwordlessRebuild` and `hush.enable` explicitly, because inheriting them quietly was how the old preset worked and losing them quietly is how the port would have failed. Say what your desktop is **for** in a comment at the top, and say what it deliberately leaves out. The reason a room is off is the part a reader can't reconstruct. ## Teach it your own first lap [#teach-it-your-own-first-lap] The built-in tour teaches haus's core moves in the bar, one hint at a time. A desktop can replace that lap with its own and stay data-only. A desktop that turns rooms off usually must, because the built-in one is leader moves plus the launcher and has nothing left to teach without them: ```nix haus.tour.steps = [ { hint = "Press {palette}, type tour, then hit ↵"; detect = "palette"; } { hint = "Move to another workspace"; detect = "workspace"; } ]; ``` Steps run in order and advance on signals haus already emits, never on keystrokes. `detect` takes one of `launch`, `workspace`, `navigate`, `resize` or `palette`; the first four need prowl, and `palette` needs the launcher *and* a palette key bound. A left click skips a step. Left at its default `null`, `tour.steps` keeps the built-in lap; an **empty** list is a type error, and the off switch is `tour.enable = false`. **Write keys as `{palette}`, `{leader}` and `{leaderName}`, never as a literal chord.** They expand to what *that* machine resolved, so a tour you share still names the right keys on a host that moved `keys.palette`, where a hardcoded `⌘Space` would be wrong invisibly, since you never see the consumer's bar. A misspelled placeholder renders as typed, and the rebuild warns and names it. A rebuild warns when a step needs a room the desktop switched off, but those warnings live inside sill's own module, so a desktop that turns the bar off gets none of them. Check that pairing by eye. ## Prove the boundary [#prove-the-boundary] Run the same structural check haus runs against its own desktops: ```sh nix eval --impure --expr \ '(builtins.getFlake "github:hausfold/haus").lib.checkDesktop ./writer.nix' ``` It prints `true`, or throws naming exactly what escaped: a stray top-level key, an `imports`, a merge or priority instruction, an option that isn't a haus option, or a leaf that is host-only because it names a person or a piece of hardware. Every rule has a fixture behind it, so the message names the file and the option rather than failing somewhere deeper. If the file is an app **pack** (a data-only file narrowed to `haus.roster`, the apps on a machine), run `lib.checkPack` instead; same idea, one level in. `checkDesktop` proves the trust boundary and the option names. To prove the *values* build a machine, select it from a real host and evaluate: ```sh nix eval .#darwinConfigurations.myhost.system.drvPath ``` [`haus plan`](/docs/haus/reference/haus) is the stronger version of that last step: it prints what a rebuild *would* change (packages, settings, files, launchd jobs, casks) and changes nothing. It builds first, so it takes longer than the eval above; it tells you correspondingly more. ## Leave room for the host [#leave-room-for-the-host] Anything your desktop sets, a consumer overrides with a plain assignment in their host file. That already works: the desktop's values arrive below a host's in the priority ladder, so nobody needs `lib.mkForce` to disagree with you. The exception is worth knowing before you set one: Every leaf a desktop sets is carried in at the desktop's priority, **a list included**, so a host that names `tour.steps`, `keys.leaderExtras` or `snippets.matches` at all replaces yours rather than appending to it. Someone who wants three of your four entries has to restate all four. That is the deliberate choice: a host says something, and the host's value is what you get. The alternative (lists concatenating) means a consumer can add to your list but can never drop one entry from it. **Attribute sets are the middle case.** `roster`, `workspaces` and `pounce.items` are carried per key, so a host that names one app's field leaves the rest of yours standing. List what your desktop sets in its README, so restating it is possible without reading the file. ## What doesn't belong in one [#what-doesnt-belong-in-one] Keep these in a private host file, or in something honestly labelled as more powerful: * identity, secrets, tokens and personal account names; * an app roster that reveals more than the desktop needs; * raw macOS defaults outside `haus.*`; * a `pkgs` value, or a `/nix/store` path; * activation hooks and shell commands; * a display UUID: `haus.displays.main` says "the screen this Mac is using" without naming anyone's hardware. `checkDesktop` refuses every one of them, so this list is what the error message will be about rather than a set of rules you have to remember. Naming a *package* is fine and doesn't need `pkgs`: options that take one have a `packageName` sibling taking the nixpkgs attribute as a string, so a desktop can change the mono font or add a tool without leaving the data-only surface. A configuration that genuinely needs `pkgs`, scripts, or arbitrary nix-darwin options is a **power module**, and carries a different trust model. # Customize a desktop (/docs/haus/desktops/customizing) A desktop ships a whole opinion, already wired. So customising it isn't assembly: it's turning a handful of dials, and every one of them lives in the same file. ## The one file you edit [#the-one-file-you-edit] There are two halves to a haus machine, and they never mix: * **haus** (`github:hausfold/haus`): the layer. Generic, no identity, upstream. You never edit it to use it. * **Your config** (`~/.config/nix`): a small flake that imports haus, plus **one host file**, `hosts//default.nix`, where you live. Everything on this page goes in that host file: ```sh haus edit # opens it in $EDITOR haus rebuild # builds first; a broken config never activates ``` It's a plain nix-darwin module, so whatever you write merges with what haus already declares. Because it's a separate repo, `haus update` pulls a newer layer without touching your identity, your apps or your overrides. ## Finding the knob [#finding-the-knob] Three ways in, and none of them is reading a reference end to end: * **Your own config already lists them.** A fresh install drops a `hosts//options.nix` beside your host file: every `haus.*` option at its default, one sentence each, **all commented out**. Uncomment what you want. `haus options` regenerates it from the build you're actually running, so it can't advertise something your machine hasn't got. If you've edited your copy it writes `options.nix.new` beside it rather than overwriting your work. * **One line, no editor.** `haus set theme.accent teal` writes the setting, type-checks it and rebuilds in one go; `haus get` reads one back and `haus reset` removes it. The [haus CLI reference](/docs/haus/reference/haus#changing-a-setting-without-opening-an-editor) has the model. * **Ask for it in words.** haus installs a skill that teaches a coding agent this whole surface, generated from the revision you run; see [Changing your Mac with an agent](/docs/haus/rooms/agent-rebuilds). Nothing in `options.nix` does anything until you uncomment a line *and* `import` it; `haus options` prints the `imports = [ ./options.nix ];` line to add if it's missing. Commented-out is deliberate, and for two reasons: a file that spelled every default out would pin every one of them against your desktop, and it would freeze haus's own defaults against ever being retuned upstream. Uncomment what you actually mean. ## The cookbook [#the-cookbook] The tour of what's worth knowing. Each block is host-file Nix. ### Identity [#identity] ```nix haus.git.name = "Ada Lovelace"; haus.git.email = "ada@example.com"; haus.git.signingKey = "6F7BD6F43A7C1420"; # "" disables commit signing haus.git.org = "analytical-engine"; # the GitHub owner your work lives under ``` `org` is the odd one out: not who you are, but where you work. gh-dash reads it to fill `⌘G`'s PR tabs with that owner's open, green, red and just-shipped work, so an org rename is one word rather than one per tab. On its own it does nothing: set `haus.hearth.ghDash.enable = true` alongside it, and keep the Git pack on (`haus.developer.git.enable`); that's what installs `gh`, and turning it off while the overlay is on stops the build. ### The look [#the-look] ```nix haus.theme.accent = "sapphire"; # any of the 14 Catppuccin accent names haus.wallpaper.style = "minimal"; # none | minimal | orbits | constellation | flow | bold ``` The accent recolours lazygit, fzf, yazi, Glow, the browser and the generated desktop together, but **not** the terminal, and not the bar beyond its logo pill. Single-file dotfiles that bake a palette at their own theme slot (Ghostty, starship, tmux, bat, zellij) keep their built-in colour on purpose, and a flake check fails the build if one of them ever starts following. `minimal` is the desktop haus generates for you (a flat field in your palette, the ⌂ mark at its centre, a bloom in your accent), and `"none"` keeps the wallpaper you already have. The [Appearance room](/docs/haus/rooms/appearance) has the whole palette and the desktop's own dials. ### The terminal [#the-terminal] ```nix haus.hearth.editorName = "neovim"; # the ONE editor: installed, and $EDITOR haus.hearth.hijackFileAssociations = false; # make that editor the default file opener ``` More in [the Development room](/docs/haus/rooms/development). ### Apps, windows and the launcher [#apps-windows-and-the-launcher] ```nix haus.roster.slack = { key = "s"; name = "Slack"; cask = "slack"; }; haus.prowl.enable = true; # false: no tiling, no Caps-Lock remap haus.pounce.enable = true; # false: ⌘Space stays Spotlight haus.pounce.signingIdentity = # sign it, and its Accessibility grant survives rebuilds "Developer ID Application: Ada Lovelace (ABCDE12345)"; ``` The roster is the most common edit on the machine and has [a page of its own](/docs/haus/rooms/apps). ### The bar [#the-bar] ```nix haus.sill.enable = true; # false: the native macOS menu bar stays haus.sill.items.elgato = true; # opt-in extras, see The bar ``` The optional pills are personal (an Elgato key-light toggle, a Harvest timer, an agents readout), so they're off unless you ask. [The bar](/docs/haus/rooms/bar) lists them. ### Homebrew behaviour [#homebrew-behaviour] ```nix haus.homebrew.cleanup = "none"; # none | uninstall | zap: what a rebuild does to undeclared apps haus.homebrew.autoUpdate = false; # brew update before each rebuild haus.homebrew.upgrade = false; # upgrade outdated packages each rebuild ``` `cleanup` is `"none"` so nothing you installed by hand is ever removed; `"zap"` gets you a fully declarative machine where an undeclared cask is uninstalled on the next rebuild. `autoUpdate` and `upgrade` are off so a rebuild is reproducible: it installs a cask once, then leaves whatever version brew first laid down. That's why an app can sit on an old version on a fully synced machine. Upgrade one on demand with `brew upgrade --cask `, or set both to `true` and accept that two rebuilds days apart can land different app versions. ## Turning rooms off [#turning-rooms-off] Every room you can see has a switch, and turning one off leaves the rest working: | Set this to `false` | And you lose | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `haus.prowl.enable` | Tiling, the leader key; Caps Lock stays Caps Lock | | `haus.sill.enable` | The custom bar; the native macOS menu bar comes back | | `haus.pounce.enable` | The ⌘Space launcher | | `haus.perch.enable` | The notch file shelf; the copied `/Applications/Perch.app` stays until you delete it | | `haus.hush.enable` | The quiet switch: bar pill, palette command and `hush` CLI | | `haus.collar.enable` | Touch ID for `sudo`, and back to the password prompt | | `haus.developer.enable` | The Development room: language toolchains, the Git tooling, the CLI toolbelt. Sub-switches (`.git`, `.toolbelt`, `.languages`) take out one piece at a time | | `haus.ai.enable` | The AI room: the agent clients, holt, and the lifecycle wiring around them | | `haus.snippets.enable` | Text expansion | | `haus.apps.videoPlayer.enable` | IINA; videos keep whatever handler they have | The macOS defaults, the Homebrew policy and the `haus` CLI itself are the foundation the rest stands on, and have no switch. Every room you can *see* does, and each one is off in the [blank desktop](/docs/haus/desktops/blank), so what's on your Mac is whatever your desktop asked for plus whatever you did. Turning **pounce** off later doesn't return ⌘Space to Spotlight: haus disables Spotlight's shortcut with a one-way write, and nothing re-enables it. Put it back yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts. (**hush** is the opposite case: it *adds* a Do Not Disturb chord macOS ships disabled, so switching the room off leaves a spare binding rather than taking one away.) If what you want is tiling **without** its keyboard claims, don't switch the room off: set `haus.keys.leader = "none"` and `haus.keys.windowNav = "none"` instead, and prowl keeps arranging windows while Caps Lock stays Caps Lock. The launcher has the same escape: `haus.keys.palette` takes `alt-space`, `ctrl-space` or `"none"`, and since only `cmd-space` displaces Spotlight, moving it is how you keep both. See [rebinding](/docs/haus/rooms/windows#not-fond-of-these-keys). ## Disagreeing with your desktop [#disagreeing-with-your-desktop] Your host file wins. Anything the desktop chose, you change with a **plain assignment** (no `lib.mkForce`, no import order to reason about): ```nix # the desktop set theme.accent = "mauve"; you'd rather have teal haus.theme.accent = "teal"; haus.sill.enable = false; # and no bar, thanks ``` That's the ladder doing its job: rooms declare defaults, a desktop states its choices above them, your host sits above both, and anything `haus set` writes sits above that. Switching desktops is one line in your flake; see [Choose a desktop](/docs/haus/desktops/choosing). Your host wins **whole**. A list-typed option (`tour.steps`, `keys.leaderExtras`, `snippets.matches`) that you name at all discards the desktop's version rather than adding to it, so if you want most of what the desktop had plus one of your own, write out all of them: ```nix haus.snippets.matches = [ { trigger = "@@"; replace = "ada@example.com"; } # the desktop's, kept { trigger = ";sig"; replace = "— Ada"; } # and mine ]; ``` To take a list away entirely, use its room's switch: `haus.tour.enable = false` rather than an empty `tour.steps`, which is a type error. ## Bigger type [#bigger-type] `haus.appearance.largePrint = true` is one named answer to a whole-machine question, and it pulls two complementary levers. `haus.ui.scale` moves haus's own type together (the terminal, the palette, the bar, the Dock, Finder's sidebar, the tiling gaps), and `haus.displays.main.uiScale` moves the *display* one step toward larger text, which enlarges everything including apps haus knows nothing about. They multiply, which is the intended effect and a bigger jump than either alone. `hausdisp list` prints the persistent UUIDs if you want to name one display rather than "main". One limit is macOS's rather than haus's: **the menu bar's height.** There is no setting for it, so the bar's type follows `ui.scale` only so far and then stops. Making the whole bar bigger is what display scaling is for: it changes what a point *means*, which is the only thing that band responds to. ## Beating a default haus set [#beating-a-default-haus-set] haus sets Dock, Finder, trackpad and keyboard defaults, and every one is a *soft* default: set the same key plainly in your host file and yours wins. ```nix # haus hides the Dock and puts it on the bottom. Put it on the left, always shown: system.defaults.dock.autohide = false; system.defaults.dock.orientation = "left"; ``` Anything under [`system.defaults`](https://nix-darwin.github.io/nix-darwin/manual/) is fair game. Two menu-bar keys track whether the bar is on, so haus sets them plainly rather than as `mkDefault`s. **`_HIHideMenuBar`** is the auto-hide: set it yourself and you get a *conflicting definition* and a failed evaluation, not a quiet win. That is the good outcome, because forcing it while the bar is on puts two bars in the same strip of pixels. **`SLSMenuBarUseBlurredAppearance`** (System Settings ▸ Menu Bar ▸ "Show menu bar background") haus rewrites on every activation, because Tahoe's Liquid Glass made the hidden menu bar's hover-reveal see-through; flip it by hand and the next rebuild flips it back. One macOS ignores outright: **`NSGlobalDomain.AppleInterfaceStyle`** (light or dark). It is a real nix-darwin option, and on macOS 26 writing it does nothing: the key is where macOS *mirrors* the appearance it's showing, not a lever, so even a plist read-back agrees with you while nothing changes. Use `haus.theme.systemAppearance`; `haus diff` flags the other one. And one that works, but not the way it reads: nix-darwin's own **`power.sleep.computer`** and its neighbours run `systemsetup`, which has no way to say *which* power source you mean: on macOS 26 one of them wrote the **charger** profile while the Mac was on **battery**, and nothing said so. Use `haus.power.*`, which says `battery` and `charger` separately. ## Secrets [#secrets] Your config is text in a git repo, so secret *values* never go in it. haus declares which secrets exist and fetches them at runtime through [secretspec](https://secretspec.dev): ```sh secretspec check # what's declared and still missing secretspec set # fill one in ``` By default they live in this Mac's login keychain, which is why they don't ride along when you clone the config onto a second machine. Point `haus.secrets.provider` at a cloud vault if you'd rather they followed you. If you'd rather share the repo than keep it private, add a [gitleaks](https://github.com/gitleaks/gitleaks) pre-commit hook so a stray key never gets to *be* a commit, and scan the history you already have **before** you flip it public. Deleting a line later doesn't unpublish the key, so rotate anything it finds. ```sh cd ~/.config/nix printf '#!/bin/sh\nexec gitleaks git --pre-commit --staged --redact --verbose\n' \ > .git/hooks/pre-commit chmod +x .git/hooks/pre-commit ``` # everyday (/docs/haus/desktops/everyday) **everyday** is the same care, aimed at a machine that will never open a terminal. A quiet bar with the clock, the battery and the weather; a search box on ⌘Space that opens anything; a wallpaper and a set of colours that agree with each other. And **none of the developer tooling**: no compilers, no agents, nothing installed for a job this Mac isn't doing. It is a whole answer, not a stripped-down one. Windows behave exactly the way they already do, because the fastest way to lose someone is to change what their machine does when they click. ```sh curl -fsSL https://hausfold.co/everyday.sh | bash ``` The URL picks the desktop, so the installer won't ask which one you want. [Install](/docs/haus/install) has the rest. This is not a five-second install: it asks for your password once, downloads a few GB, and sets things up so it doesn't have to ask again. If you're doing it for someone else, do it while sitting next to them. ## What's in it [#whats-in-it] | | | | --------------------------------------------- | --------------------------------------------------------------------------------- | | **[the bar](/docs/haus/rooms/bar)** | A clock, battery and weather along the top edge: a better menu bar, not a new one | | **[the launcher](/docs/haus/rooms/launcher)** | ⌘Space opens a search box that finds and opens things | | **[the shelf](/docs/haus/rooms/shelf)** | A place in the notch for files on their way somewhere else | | **[Focus](/docs/haus/rooms/focus)** | One key to go quiet, and one to come back | | **[the look](/docs/haus/rooms/appearance)** | The haus mark on your palette as a wallpaper, and apps themed to agree with it | | **a video player** | IINA, which opens the formats Quick Look won't | | **a first lap** | One prompt, once: press ⌘Space, type, hit return. That is the whole tutorial | ## The judgement calls [#the-judgement-calls] A desktop is a set of decisions somebody made on your behalf, so these are the four worth seeing before you install it. Every one of them is a line in your own file away. **Tiling is off.** Tiling is good; remapping Caps-Lock to a leader key on someone else's Mac is not. Windows keep the behaviour they already know, which is also why this desktop claims no leader key at all. **The launcher is on.** A search box that opens things is legible to anyone. It is the one power feature that needs no explanation. **The bar is on.** A clock, a battery and the weather is a better menu bar, not a different way of thinking about the screen. **Developer tooling is off, and agents with it.** Coding agents on a machine that ships no coding tools is a room enabling itself for nobody. If you want them, `haus.ai.enable = true` is the line. This is the same idea rebuilt as a whole desktop rather than a layer over another one, and two things it used to inherit are now deliberately off: the coding agents, and the Node toolchain that came with them. [`desktops/everyday.nix`](https://github.com/hausfold/haus/blob/main/desktops/everyday.nix) states every difference in full. ## Where to go next [#where-to-go-next] # hacker (/docs/haus/desktops/hacker) **hacker** is a Mac arranged like a tiling Linux rig, but native to the grain of the Mac: fog-grey, keyboard-first, and the same after you wipe the machine. It is the desktop you get if you don't pick one, and the one most people meet haus through. You never edit the desktop to use it. The installer scaffolds a thin config of your own at `~/.config/nix` that consumes it as an input, so your machine stays yours and haus stays upstream. ```sh curl -fsSL https://hausfold.co/nebelhaus.sh | bash ``` That URL picks this desktop, so the installer skips the question. [Install](/docs/haus/install) has the rest: what the script does, how to read it first, and the one command that actually changes the Mac. ## What it turns on [#what-it-turns-on] haus is organised into [rooms](/docs/haus), each one a capability with its own switch. hacker turns nearly all of them on. | Room | What you get | | ----------------------------------------------- | ----------------------------------------------------------------------------------------------- | | **[Windows](/docs/haus/rooms/windows)** | Tiling launched at boot, and Caps-Lock as the leader key instead of a chord | | **[Bar](/docs/haus/rooms/bar)** | Workspaces, weather, media, battery, clock, and a light that reddens when something has stopped | | **[Launcher](/docs/haus/rooms/launcher)** | [pounce](/docs/pounce) on ⌘Space, where every command is a file you can write | | **[Shelf](/docs/haus/rooms/shelf)** | [perch](/docs/perch), dropping out of the notch to catch a drag on its way somewhere else | | **[Development](/docs/haus/rooms/development)** | Ghostty, zellij, zsh, a tinted prompt, helix and a toolbelt that all matches | | **[Focus](/docs/haus/rooms/focus)** | One hotkey for Do Not Disturb, your Slack status, and your own hooks | | **[Security](/docs/haus/rooms/security)** | Touch ID for `sudo`, inside a multiplexer too, and secrets declared rather than pasted | | **[AI](/docs/haus/rooms/ai)** | Coding agents, each in its own checkout of the repo, so they never collide | | **[Appearance](/docs/haus/rooms/appearance)** | The nebelung palette and a generated wallpaper, rendered onto twenty-odd tools at once | Anything it chose, your own file overrides in a line; see [Customize a desktop](/docs/haus/desktops/customizing). ## First moves [#first-moves] The first switch has finished. Windows now tile themselves, the menu bar has been replaced, your terminal opens into a themed session, ⌘Space belongs to the palette, and `haus` is on your `PATH`. Here's the muscle memory. | Keys | What happens | | ------------------------ | ------------------------------------------------------------------------------------ | | **⌘Space** | The palette. Type a few letters, hit Return. Start here for everything | | **Tap ⇪, then a letter** | Launch or focus that app. `T` is the terminal, `B` the browser | | **Tap ⇪, then an arrow** | Move focus between tiled windows. Keep arrowing; hold ⇧ to *move* the window instead | | **Tap ⇪, then a digit** | Jump to that workspace. Add ⇧ to throw the current window there | | **Tap ⇪, then /** | The cheatsheet, generated from *your* app list, so it is never out of date | It's a leader key now, not a modifier. Tap and release, then press the letter. Try these in order: tap **⇪ T** and the terminal opens and tiles itself; **⌘Space**, type `saf`, Return and Safari launches; **⇪ then ← / →** jumps focus between the two; **⌥/** flips the split between horizontal and vertical; **⌘Space**, type `emoji`, Return turns the palette into an emoji grid. One permission to grant: a few palette features use a keyboard path macOS gates behind Accessibility. Everything else works without it. ```sh pounce --request-accessibility ``` The rest of the keys are the rooms' own, and they are written out in full where they're configured rather than copied here: [Windows](/docs/haus/rooms/windows) has tiling, the leader and every workspace throw, including how to move all of it onto keys you'd rather use; [Development](/docs/haus/rooms/development) has the terminal's chord set; [pounce](/docs/pounce/using) has the palette. There is also a live copy on your own machine (tap **⇪ /**), generated from your own tables, so use that one when they disagree. ## Making it yours [#making-it-yours] Your settings live in one file, and `haus edit` opens it. The tweaks people reach for first are an accent, an editor, and an app with a launcher key and a workspace of its own. ```nix haus.theme.accent = "sapphire"; # one accent, everywhere haus.hearth.editorName = "neovim"; # named, so haus installs it haus.roster.slack = { # an app, with a launcher key key = "s"; name = "Slack"; appId = "com.tinyspeck.slackmacgap"; cask = "slack"; }; haus.workspaces.S = { # …and a workspace of its own key = "s"; icon = ":slack:"; apps = [ "slack" ]; }; ``` Then `haus rebuild`. [Customize a desktop](/docs/haus/desktops/customizing) is the rest of what that file can say, and [the haus reference](/docs/haus/reference/haus) is every command the CLI has. ## Where to go next [#where-to-go-next] # minimal (/docs/haus/desktops/minimal) **minimal** is the terminal, finished (a tinted prompt, a themed toolbelt, and the colours to match) on an otherwise **stock macOS**. No bar, no tiling, no launcher, no shelf. Nothing that changes how the Mac behaves outside the window you type in. It is still a developer machine. Minimal here means **few rooms, not few tools**: the toolbelt is the same one hacker ships. If what you want is a Mac with no developer tooling on it at all, that one is [everyday](/docs/haus/desktops/everyday). ```sh curl -fsSL https://hausfold.co/minimal.sh | bash ``` The URL picks the desktop, so the installer won't ask which one you want; or `nix run github:hausfold/haus#bootstrap` straight from the flake once Nix is installed. [Install](/docs/haus/install) has the rest. ## What's in it [#whats-in-it] Two rooms, plus the shell every desktop starts with; that one has no switch and ships with every desktop, `blank` included, so it isn't counted here. Nothing reaches past the terminal window. Each room is the same module hacker uses: this desktop selects fewer of them, it doesn't use lesser ones. | | | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **[Development](/docs/haus/rooms/development)** | zsh, a tinted starship prompt, git, helix, the toolbelt with Node wired up; and zellij opens locked, so the multiplexer's own keys stay out of your way until you ask for them | | **[Security](/docs/haus/rooms/security)** | Touch ID for `sudo`, and rebuilds that don't stop to ask twice | | **[the theme](/docs/haus/rooms/appearance)** | The accent the palette is built from: mauve, until you say otherwise. Tuning, not a room | Mono is set to 19pt, up from the room's 13: a deliberate default rather than a leftover. This desktop *is* a terminal, and a terminal you read all day is worth the size. ## What it deliberately leaves out [#what-it-deliberately-leaves-out] Every one of these is absent for the same reason: it would reach outside the terminal, which is the one promise this desktop makes. All of them are a line in your own host file away if you disagree. | | | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | **coding agents** | A room of their own; `haus.ai.enable = true` brings them here | | **theme ports** | They write theme files into apps this desktop never installed | | **the wallpaper** | The desktop picture is not the shell | | **a video player** | An editorial app pick, and the one thing here you may miss from hacker; `haus.apps.videoPlayer.enable = true` brings IINA back in one line | | **Focus, and the tour** | A quiet switch and a tutor both teach moves this selection doesn't ship | ## Where to go next [#where-to-go-next] # Share a desktop (/docs/haus/desktops/sharing) A desktop is one readable file, so sharing one is mostly about making it easy to *read*, and about being honest in the README, which is the part the format can't check for you. ## Publish something inspectable [#publish-something-inspectable] The smallest useful repository is deliberately boring: ```text writer-desktop/ ├── README.md ├── writer.nix └── LICENSE ``` For a first release, invite people to vendor the `.nix` file into their own config rather than fetching it: ```sh curl -O https://raw.githubusercontent.com/you/writer-desktop/main/writer.nix mv writer.nix ~/.config/nix/desktops/ ``` ```nix desktop = ./desktops/writer.nix; ``` A flake wrapper can come later if versioned imports turn out to be useful. It should not hide the data people are being asked to trust, and any wrapper owes every module it rebuilds a `_file`, or the conflicts it causes name no file at all. ## What the README owes a reader [#what-the-readme-owes-a-reader] Someone deciding whether to run your desktop is deciding what their Mac will feel like tomorrow. Tell them: * **who it's for**: the kind of person or the kind of work, in a sentence; * **which rooms it turns on, and which it deliberately leaves off**, with the reason for the off ones. That reason is the part they cannot reconstruct from the file; * **the strong opinions**: a claimed global hotkey, a remapped Caps Lock, a changed default browser, anything that will surprise muscle memory; * **which haus revision you tested against**; * **what it deliberately doesn't handle**: permissions it expects the person to grant, hardware it assumes, apps it doesn't install; * **any list-typed option it sets** (`tour.steps`, `keys.leaderExtras`, `snippets.matches`), because a host that names the same one [replaces yours whole](/docs/haus/desktops/creating#leave-room-for-the-host) and has to restate the entries it wants to keep; * **that both `checkDesktop` and a real host evaluation passed**. You do not need to document how to override you. Anything a desktop sets, a host beats with a plain assignment. That is a property of the seam, and it holds for every desktop without either of you doing anything. ## App packs are the neighbouring format [#app-packs-are-the-neighbouring-format] If what you want to share is a *set of apps* rather than a whole machine, that's a **pack**: a data-only file that sets nothing but `haus.roster`, imported through `haus.lib.pack`. It lowers every field it sets, so a consumer's own host wins with a plain assignment. A pack is not a desktop and doesn't compete with one: a machine has one desktop and as many packs as it likes. ## Before you share [#before-you-share] * [ ] `checkDesktop` prints `true`, and a real host evaluates. * [ ] You have run it on a Mac, not only evaluated it. * [ ] Every room it turns off agrees with the features that depend on that room, `tour.steps` included. * [ ] The README states the audience, the rooms, the strong opinions, the tested revision and the limits. * [ ] Any list-typed option it sets is named in the README. * [ ] There is no identity, secret or device-specific data anywhere in it. # Install (/docs/haus/install) ```sh curl -fsSL https://hausfold.co/haus.sh | bash ``` That installs Nix if you don't have it, asks you a few questions (including which desktop you want), and writes your config. It does **not** change your Mac on its own: it writes files, prints the one command that activates them, and asks before running it for you. So the whole install is two moves, and you can stop between them and read what the first one wrote. (The pipe doesn't cost you the questions: the installer reaches your terminal directly, so `| bash` interviews you like any other form. In CI, a container, or a run whose output you've captured to a file, no one is watching, so it takes the defaults and never offers to switch.) ## If you already know which desktop you want [#if-you-already-know-which-desktop-you-want] Every desktop has its own URL, and typing it answers the question before it's asked: ```sh curl -fsSL https://hausfold.co/hacker.sh | bash # the full desktop curl -fsSL https://hausfold.co/everyday.sh | bash # for someone who doesn't write code curl -fsSL https://hausfold.co/minimal.sh | bash # just the themed shell ``` They're the same script: the URL sets the desktop and the installer skips that one question. `haus.sh` is the one that asks. A Mac runs exactly one desktop, and whichever you pick, changing your mind later is a line in your own config, not a reinstall. Piping to `bash` passes no arguments, which is why the desktop rides in the URL rather than on a flag. If you'd rather use the flag, give `bash` a `-s --`: ```sh curl -fsSL https://hausfold.co/haus.sh | bash -s -- --desktop=minimal ``` Every desktop is a set of values for the same options. Anything one of them chose, your own file overrides in a line, so picking is a starting point, not a commitment. ## Before you start [#before-you-start] * A **Mac on Apple Silicon**, running a recent macOS. Intel isn't supported. * **Xcode Command Line Tools.** The installer prompts for them if they're missing, then asks you to re-run. * **10–15 minutes** and a few GB of download for the first build. The bootstrap installs [Determinate Nix](https://docs.determinate.systems/) for you. If you already run stock Nix it stops and explains rather than touching your install. ## Read it first, if you like [#read-it-first-if-you-like] `curl … | bash` runs whatever the URL serves, so it's fair to want to look. The URL only proxies [`bootstrap.sh`](https://github.com/hausfold/haus/blob/main/bootstrap.sh) from the public repo; it's boring on purpose. ```sh curl -fsSL https://hausfold.co/haus.sh | less # or pin an exact release and diff it against GitHub curl -fsSL 'https://hausfold.co/haus.sh?ref=v2026.07.18' | less ``` A desktop's URL serves that same file with two lines added at the top, saying which desktop you asked for. `haus.sh` adds nothing at all, so it's the one to diff against GitHub. By default you get the latest release tag. Releases are date-tagged, so `?ref=v2026.07.18` pins a specific day, and a **release tag is the only thing `?ref=` accepts**. Anything else is refused rather than fetched, which is what stops a link with someone else's commit in it from serving you a script off this domain. ## What it does [#what-it-does] ### Takes a snapshot [#takes-a-snapshot] Confirms you're on macOS and takes a Time Machine local snapshot as a coarse rewind point. Non-fatal if Time Machine is off. ### Asks you a few things [#asks-you-a-few-things] Your git name and email, a desktop (hacker / everyday / minimal / custom), an accent colour, a wallpaper, and an editor: helix, neovim, vim or nano, and haus installs the one you name. It also asks whether to keep your current Dock, keyboard and Finder settings, and whether to adopt the Homebrew casks you already have. ### Shows you what would change [#shows-you-what-would-change] A read-only report before anything is written: which dotfiles get backed up, which macOS settings change, and which casks it found. **No app you installed is ever removed.** ### Writes your config [#writes-your-config] Creates `~/.config/nix/` (a small flake that imports haus, plus a host file holding your answers) and commits it to git. Nothing is active yet. Wherever there's a terminal to ask on, piped or not, the last thing it asks is whether to change that: say yes and it runs the two commands below for you, then `haus doctor`. Unattended, it stops here and prints them. ## Build and switch [#build-and-switch] ```sh cd ~/.config/nix nix build .#darwinConfigurations.$(scutil --get LocalHostName).system \ && sudo ./result/sw/bin/darwin-rebuild switch --flake . ``` The build runs first, and a failed build never touches your system. When it succeeds, everything lands at once: apps install, the bar and tiling start, your terminal is themed, and `haus` joins your `PATH`. The first run is slow; it's downloading the world. After that, `haus rebuild` is the only command you need. ## If you regret it [#if-you-regret-it] | To undo | Use | | ------------------- | ----------------------------------------------------------------- | | The macOS settings | the snapshot from step 1 | | Everything haus did | `haus rollback` (atomic, previous generation) | | Apps | yours. haus never uninstalls one you added | | All of it | [Leaving](/docs/haus/leaving), every exit in turn, smallest first | ## Unattended installs [#unattended-installs] Every question has an environment variable, so the whole thing can run without a prompt: ```sh HAUS_NONINTERACTIVE=1 \ HAUS_GIT_NAME="Ada Lovelace" \ HAUS_GIT_EMAIL="ada@example.com" \ HAUS_ACCENT="mauve" \ bash -c "$(curl -fsSL https://hausfold.co/haus.sh)" ``` | Variable | Effect | | ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `HAUS_NONINTERACTIVE=1` | Skip the questions, take the defaults | | `HAUS_DRY_RUN=1` | Print the config it would write, write nothing. Still asks the questions if you have a terminal; pair it with `--defaults` for a silent run | | `HAUS_DESKTOP` | Which desktop to select (`hacker`, `everyday`, `minimal`, `blank`), or `--desktop=` on the command line. Setting it skips that question even in an interactive run, which is what a desktop's own URL does for you | | `HAUS_ROOMS` | Which rooms to turn on, one by one, instead of taking a desktop's selection | | `HAUS_ACCENT` / `HAUS_WALLPAPER` / `HAUS_EDITOR` | The look and the editor | | `HAUS_KEEP` | macOS settings to leave alone (`dock,keyboard,finder`) | | `HAUS_DIR` | Scaffold somewhere other than `~/.config/nix` | | `HAUS_FROM` | Restore an existing config instead of scaffolding | Every variable above was spelled `NEBELHAUS_*` until 2026-08-14, and **the old spelling is still read**: the installer promotes it before anything else runs, with the new name winning if both are set. Same for the desktop names it accepts: `hacker` was `nebelhaus`, and the pre-desktops `HAUS_PRESET=full` still selects it. Nothing you already automated needs changing. These are also what you want if you hand the install to a coding agent: the questions are a terminal UI, and a tool driving it blind is how someone else's git identity ends up in your config. An agent can do everything up to the build; the two `sudo` steps are yours. ## Next [#next] # Contributing (/docs/haus/internals/contributing) haus is one repo in a family of them, and a change usually has to travel between several before you can feel it. This page is that path. To *use* haus you need none of it; [Keeping it current](/docs/haus/keeping-it-current) is that page. ## The workshop [#the-workshop] The [workshop](https://github.com/hausfold/workshop) is a parent directory that holds every family repo checked out side by side, plus a `bench` dev CLI: ```sh git clone https://github.com/hausfold/workshop.git cd workshop ./bench clone ``` You end up with `haus/`, `nebelung/`, `pounce/`, `perch/`, `holt/` and the rest as independent git repos, with `bench` sitting above them. ## Where a change goes [#where-a-change-goes] Every change belongs to exactly one repo, and a colour hex in haus or launchd logic in the launcher is in the wrong one even when it works: | Change | Repo | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | macOS defaults, tiling, the bar, the shell, Touch ID | [`haus`](https://github.com/hausfold/haus) | | colours, the palette, how a tool is themed | [`nebelung`](https://github.com/hausfold/nebelung) | | the launcher app, or a generic command script | [`pounce`](https://github.com/hausfold/pounce) | | the notch file shelf | [`perch`](https://github.com/hausfold/perch) | | how agent worktrees are made, parked or reaped | [`holt`](https://github.com/hausfold/holt) | | the quiet notification banners | [`trill`](https://github.com/hausfold/trill) | | the Homebrew formula and cask | [`homebrew-tap`](https://github.com/hausfold/homebrew-tap) (CI-owned; a hand-edit is overwritten) | | `bench` itself | the workshop | | these docs | [`hausfold.co`](https://github.com/hausfold/hausfold.co), and the workshop's `web/` until nebelhaus.com retires | | your own machine's apps, identity, secrets | `~/.config/nix`, which is yours | Four command-line tools come out of that, and only the first is for people who merely *use* haus: | Command | Reach for it to… | Ships in | | -------------- | ------------------------------------------------------------------------------------- | ------------------------------------- | | **`haus`** | drive your own Mac: [rebuild, update, roll back, diagnose](/docs/haus/reference/haus) | haus, always | | **`holt`** | manage [agent worktrees](/docs/haus/rooms/ai) in any git repo | haus, with `haus.ai.enable` on | | **`zscratch`** | feel-test a zellij edit with no rebuild | haus, with `haus.developer.enable` on | | **`bench`** | move a change across the family repos | the workshop (contributors only) | `haus` knows only *your machine*; `bench` knows only *the family repos*. ## Try it without pushing [#try-it-without-pushing] You never have to push to *see* a change. `bench try` builds your real machine config against the **local checkouts**, uncommitted edits and all, using Nix's `--override-input`: ```sh ./bench try # does it build? nothing pushed, nothing activated ./bench try switch # run it on this Mac, still nothing pushed ``` When you're happy, commit in the repos you touched and let `bench ship` push upstream→downstream, bumping a lock at every hop so the change actually reaches your Mac without you hand-walking the [pin chain](/docs/haus/internals/flakes#why-a-pin-means-nothing-changes-until-you-say-so). It refuses a dirty tree, and fast-forwards each checkout from origin first: a lock computed off a stale `main` pins the pre-merge commit and reports success. `./bench status` opens with what this Mac is actually running, then every repo's git state and every stale lock edge. An edge can also be **off-main**: pinned at a rev that isn't on that repo's `main`, which is what a `nix flake update` inside an open PR leaves behind. It resolves until the branch is deleted on merge, and then the downstream repo can't fetch its input at all, so land the upstream PR *first*, then ship. `./bench rebuild` is the way back to the pinned build. ## Working with agents [#working-with-agents] The family is built to be hacked on by several coding agents at once, each in its own worktree. That mechanism is [the AI room](/docs/haus/rooms/ai) and it works for any repo; two things are specific to a *family* one. * **`bench try` is worktree-aware.** Run it from inside a worktree and that repo's override points at *your branch*, so a branch can prove it builds before anyone merges it. It substitutes one lane, and only for a repo in the ripple chain; from a *workshop* worktree it overrides nothing. When a change spans repos, `bench try lane` builds your lane together with every `holt child` lane spawned from the same pane, in one rebuild and with no PR. * **`bench try switch` works from a worktree too**, and is the only way to feel one unmerged branch alone. The gate is on **who, not where**: a person at the keyboard runs it, an agent is refused. Activation is machine-wide and serial, so parallel agents would overwrite each other's Mac. Every switch leaves a receipt `bench status` reads back. ## Feel the whole queue at once [#feel-the-whole-queue-at-once] Activating a Mac is serial, so a stack of open PRs can normally only be felt one at a time, and merging them first to save the trouble puts unverified code on `main`. `bench try-batch` inverts that: per repo it builds a throwaway integration tree (`main` plus every open PR merged in), overrides the flake there, and builds or activates the whole queue in **one** rebuild, `main` untouched. ```sh ./bench try-batch # build every open PR together, with a tick-off checklist ./bench try-batch switch # and activate the combined tree on this Mac ``` Then merge only the ones that passed. Test-then-merge, not merge-then-test. A PR that conflicts with the batch, or whose head branch isn't on origin, is **excluded** and named in a footer under the checklist. Tick the checklist off without reading that footer and you'll believe you felt a PR the batch never built. ## Feel-testing a zellij edit [#feel-testing-a-zellij-edit] A zellij plugin `.wasm`, a patched binary, or a layout change to tabs that already exist can't hot-reload (a running server holds its plugin wasm in memory for its whole lifetime), so seeing one means restarting the server and losing every open tab. `zscratch` boots your candidate as a throwaway session in its own window instead, rendered over a copy of the live `~/.config/zellij`: ```sh zscratch --config config.kdl # a candidate config.kdl zscratch --layout my-layout.kdl # …or a layout zscratch --theme nebelung.kdl # …or a theme zscratch --plugin tab-bar=tab-bar.wasm # …or a freshly built plugin zscratch --bin /path/to/zellij # …or a binary-level patch zscratch clean # kill the scratch session and temp dir ``` A plain `config.kdl` edit needs none of this: zellij applies most of that file to the *running* server in about a second, so `bench try switch` is enough and your tabs, panes and live agent sessions stay put. ## The whole life of a change [#the-whole-life-of-a-change] ```text hack ─► test ─► assure ─► PR ─► batch-test ─► merge ─► try switch ─► ship ─► release ``` 1. **hack**: in place, or on `worktree-*` branches in parallel. 2. **test**: `./bench try`, which from inside a worktree builds *that* branch. 3. **assure**: hand `git diff main...HEAD` to a clean-context reviewer that has read nothing but the diff and the repo's `AGENTS.md`. Advisory, and it catches what only bites after merge: a change in the wrong repo, docs left stale by a renamed option, a colliding hotkey. 4. **PR**: against `main`, with a **What / Why / Verify / Watch-out** body. Never a direct push or a local merge. 5. **batch-test**: `./bench try-batch`, the whole queue in one rebuild. 6. **merge**: the ones that passed; leave the rest open. 7. **try switch**: on `main`, now that it holds the work. 8. **ship**: `./bench ship` ripples the locks in dependency order. 9. **release**: `./bench release `; CI does the rest. Three repos take CalVer (the launcher, the shelf and haus), and their version is never typed by hand. `bench release ` stamps today's date (`YYYY.MM.DD`, or `-N` on a same-day repeat) into the repo's version source, commits it and tags it; passing a version to a CalVer repo is refused. CI then publishes the release, and for the two app repos bumps the formula and the cask. The command blocks while CI runs, drawing the jobs live, exits non-zero on red and fast-forwards your checkout on green; `--ship` ripples the new lock edge in the same breath. A haus release is what the install one-liner serves, so a user-visible haus change isn't *out* until it's tagged. And **holt is the exception, forced**: `bench release holt 0.2.0` takes a semver and refuses to run without one, because its SDKs publish to registries where a number, once published, can never be withdrawn. Each repo carries its own `AGENTS.md` with the deep rules for its boundary; start there when you open one. ## Built on [#built-on] * [Nix](https://nixos.org) and [nix-darwin](https://github.com/nix-darwin/nix-darwin), the reproducible system * [AeroSpace](https://github.com/nikitabobko/AeroSpace) for tiling; [SketchyBar](https://github.com/FelixKratz/SketchyBar) for the bar * [Catppuccin](https://github.com/catppuccin), the framework nebelung is derived from * the terminal stack: [Ghostty](https://ghostty.org), [zellij](https://zellij.dev), [yazi](https://yazi-rs.github.io), [helix](https://helix-editor.com), [starship](https://starship.rs) haus, nebelung and pounce are MIT. **holt** is Apache-2.0, and **perch** is *fair source*: [FSL-1.1-ALv2](https://fsl.software), which turns into Apache-2.0 two years after each release ships. Read it, build it, change it for yourself, but don't sell it as a competing product. # How the flakes fit together (/docs/haus/internals/flakes) Nothing about your machine changes until you ask for it, and no two machines built from the same config disagree. Both of those come from the same place: haus is a [Nix flake](https://nix.dev/concepts/flakes.html), your config is another one that imports it, and every import is pinned to an exact commit. ## Your config is a thin consumer [#your-config-is-a-thin-consumer] The installer scaffolds `~/.config/nix` as a flake that takes haus as an input and calls its builder: ```nix { inputs.haus.url = "github:hausfold/haus"; outputs = { haus, ... }: { darwinConfigurations. = haus.mkHaus { username = ""; hostname = ""; host = ./hosts/; }; }; } ``` (The input's name is yours to choose; it is a local handle and nothing outside your flake reads it. `haus` is what the installer scaffolds; configs written before 2026-08-14 say `nebelhaus`, after the desktop rather than the layer, and keep working. Rename it only together with every use of it in the same file.) Your choices live in `hosts//default.nix`, [the one file you edit](/docs/haus/desktops/customizing). haus stays upstream, and you never edit it. ## The builder [#the-builder] `mkHaus` is the entry point. Given a user, a hostname and a host file it returns a whole nix-darwin system with every room wired up: ```nix haus.mkHaus { username = "ada"; hostname = "fog"; host = ./hosts/fog; # your identity and your choices system = "aarch64-darwin"; # Apple Silicon only extraModules = [ ]; # more nix-darwin modules, or a desktop } ``` Only `username` and `hostname` are required. `desktop` selects the one [desktop](/docs/haus/desktops/choosing) this machine runs (the argument the installer writes for you), and `extraModules` is the seam for any other nix-darwin module, an app pack included. Eight of haus's modules (`den`, `hearth`, `prowl`, `sill`, `collar`, `pounce`, `hush`, `secrets`) are also exported on their own under `darwinModules`, alongside a `default` holding the lot. Reach for one of those only with the builder still around it: a module declares no options of its own and takes the username from `mkHaus`, so on its own it doesn't evaluate. ## The inputs [#the-inputs] The haus flake pulls together ten: | Input | What it is | | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `nixpkgs` | the package set, tracking unstable | | `nix-darwin` | the macOS system modules | | `home-manager` | your user environment | | `catppuccin` | the theme framework nebelung derives from | | `nebelung` | the palette | | `pounce` | the launcher app | | `perch` | the notch file shelf; this input tracks perch's default branch, but what gets *built* is the notarized release zip perch's own flake pins | | `holt` | the worktree substrate, put on your `PATH` when `haus.ai.enable` is on | | `zmx` | the session backend an agent lane's window uses when `haus.hearth.lanes.backend = "zmx"`; the one input that deliberately doesn't follow our `nixpkgs`, since it builds through zig2nix against a pinned Zig | | `nix-index-database` | the index behind `nix-index` and `comma` | ## Why a pin means "nothing changes until you say so" [#why-a-pin-means-nothing-changes-until-you-say-so] Every input is recorded in `flake.lock` as an **exact commit hash**, never as "latest". That is what makes a rebuild reproducible: the same lock produces the same system on any machine, on any day. The flip side surprises everyone: pushing a change to the palette or to haus changes **nothing** on your Mac until your `flake.lock` is bumped to the new commit. A single colour change therefore has to walk the whole chain, each link a lock pinning the exact commit of the one before it: ```text nebelung ──► pounce ──► haus ──► ~/.config/nix ──► your Mac ``` You never walk it by hand. [`haus update`](/docs/haus/keeping-it-current#pulling-a-newer-haus) moves your pin to the latest haus (which already has its own inputs pinned), upgrades the family's Homebrew apps, and rebuilds. Walking the *upstream* half is a contributor's job, and `bench ship` does it; see [Contributing](/docs/haus/internals/contributing). ## Cold-boot safety [#cold-boot-safety] Nix lives on its own APFS volume, and at cold boot macOS will try to launch haus's three GUI agents (AeroSpace, SketchyBar and Pounce) before that volume is mounted. haus wraps those three in a shim that waits for the Dock, Finder and SystemUIServer first, then gives up after a minute and launches anyway. That is why your bar and your tiling come back after a reboot rather than racing the login. # Keeping it current (/docs/haus/keeping-it-current) Every change to a haus machine is the same two beats: **edit the file, run one command.** Pulling a newer haus, undoing yesterday's change and rebuilding the whole setup on new hardware are that same loop, started from different places. ## The everyday loop [#the-everyday-loop] ```sh haus edit # your host file, in $EDITOR # …change something… haus rebuild # build first, then switch ``` `haus rebuild` **builds before it switches**, so a config with a mistake in it never reaches the running system: you get a build error and a machine still exactly as it was. For a one-line change, skip the editor: `haus set theme.accent teal` writes the setting, type-checks it and rebuilds in one go. The [`haus` reference](/docs/haus/reference/haus) has the rest. haus ships a **Rebuild System** command into the [launcher](/docs/haus/rooms/launcher) (⌘Space, type "rebuild"), which runs `haus rebuild` in a floating terminal, and a **Nix Config** command that opens your host file. ## Pulling a newer haus [#pulling-a-newer-haus] ```sh haus update ``` haus lives upstream, pinned to an exact commit in your config's `flake.lock`. **Nothing about your machine changes until you run this**: no background updater, no surprise on a Monday. `haus update` moves that one pin to the latest haus, upgrades the family's Homebrew apps alongside it, prints what changed upstream, and rebuilds. `haus status` says where you stand: your current generation, the revision you're pinned to, and whether upstream has moved past it. If you want the machinery behind that pin (what a flake input actually is, and why a pushed commit reaches you only when a lock moves), that's [How the flakes fit together](/docs/haus/internals/flakes). ## When a change goes wrong [#when-a-change-goes-wrong] ```sh haus rollback # back to the previous generation, atomically haus generations # what you can roll back to haus doctor # health check, when it isn't obvious what broke ``` Every switch creates a generation, and `rollback` steps back to the last one in seconds. When it's a symptom rather than a change (a dead bar, a launcher that won't answer), [Troubleshooting](/docs/haus/reference/troubleshooting) has the known quirks and the command for each. A generation holds everything Nix wrote (packages, services, login agents), but **not** the macOS preferences a rebuild set imperatively: the Dock, Finder, the keyboard. `haus capture` before a risky change snapshots those, and `haus revert-settings` puts the snapshot back. ## A second Mac [#a-second-mac] Your config is a git repo, so a machine you already set up catches up the way any checkout does, assuming you pushed it somewhere both machines can reach: ```sh cd ~/.config/nix && git pull haus rebuild ``` Both machines read the same pin from the same lock file, so they converge on the same system rather than on roughly the same one. ## A new Mac, or a wiped one [#a-new-mac-or-a-wiped-one] This is the payoff for describing a machine as text: a restore, not a weekend of clicking through System Settings. It assumes one thing: that `~/.config/nix` is **pushed somewhere you can clone it from**. If it isn't, that is today's five-minute job, not next month's. ### Point the installer at your config [#point-the-installer-at-your-config] The one-liner that scaffolds a fresh setup also restores an existing one: it clones your repo into `~/.config/nix` and skips the interview entirely. ```sh curl -fsSL https://hausfold.co/haus.sh | bash -s -- --from ``` On a brand-new Mac the Xcode Command Line Tools are missing, so the first run opens Apple's installer dialog and stops. Approve it, then run the line again. Determinate Nix it installs for you. (By hand, `--from` is nothing more than `xcode-select --install`, [Determinate Nix](https://docs.determinate.systems/), and `git clone`.) ### Build, then switch [#build-then-switch] ```sh cd ~/.config/nix nix build .#darwinConfigurations.$(scutil --get LocalHostName).system \ && sudo ./result/sw/bin/darwin-rebuild switch --flake . ``` The installer prints this pair for you, with your hostname already filled in. The first build downloads the world and takes a while; when it lands, `haus` is on your `PATH` and the machine is yours again; `haus doctor` is the check that it all came back. Your config declares each machine by hostname, and a new one isn't picked up on its own. Either build the old host by name (`--flake .#`) or copy `hosts//` to `hosts//` and add it to `flake.nix` beside the first. Your identity and app choices come along either way. ```sh nix eval ~/.config/nix#darwinConfigurations --apply builtins.attrNames ``` ### What comes back on its own [#what-comes-back-on-its-own] That one switch restores every app in `haus.roster` that names a Homebrew cask or formula or a nixpkgs package, plus the whole terminal, the macOS defaults haus manages down to the Caps-Lock remap and ⌘Space, and the launchd agents behind tiling, the bar and the launcher. On macOS Tahoe and later that last group has a catch: **Background Task Management can quietly refuse to start them on a fresh machine**, and `haus btm` prints the one thing to click. App Store entries are the exception: installing from an `appStoreId` is opt-in (`haus.appStore.install`), and even switched on it can only fetch apps your Apple account already owns; a paid app has to be bought in App Store.app once, by you. ### The bits Nix doesn't carry [#the-bits-nix-doesnt-carry] A handful of things never enter your config, on purpose. Walk this list once per machine: * **Secrets.** Your config declares *which* secrets exist, never their values. `secretspec check` lists what's still missing and `secretspec set ` fills one in. By default they live in this Mac's login keychain, which is exactly why they don't ride along in the clone; point `haus.secrets.provider` at a cloud vault and they follow you instead, at the cost of one login to bootstrap it. * **Commit signing keys.** Your config names the key *id*; the key *material* is yours to import, along with whatever agent unlocks it. * **Pounce's Accessibility grant.** macOS ties it to the machine, so run `pounce --request-accessibility` once more. If you set `haus.pounce.signingIdentity`, import that certificate into the new keychain too; that's what makes the grant survive later rebuilds ([Pounce config](/docs/pounce/config)). * **Apps you installed by hand.** They won't reappear. Reinstall the ones you want, and consider moving them into a [roster entry](/docs/haus/rooms/apps) so next time they do. * **Logins and licences.** Nix restores the app, not your session; sign back into the handful that need it, the App Store among them. Every app you move off that list and into `haus.roster` is one less thing to remember next migration. The goal is a Mac you could wipe on a Tuesday without flinching. That's not a restore, and the [install](/docs/haus/install) flow handles it deliberately: your existing Homebrew casks are kept, dotfiles are backed up rather than clobbered, your current macOS settings can be captured into the new host file, and nothing activates until you say so. Run from a terminal, it *offers* to build and switch at the end; piped, it just prints the command. # Leaving (/docs/haus/leaving) haus is opinionated, not possessive. Switching off one room, returning to an earlier generation, removing the system layer and removing Nix are four operations of very different sizes. **Start with the smallest one that solves your problem.** | What you want | Where to go | | --------------------------------------------------- | --------------------------------------------------------- | | Undo the last rebuild | [Roll back a generation](#roll-back-a-generation) | | Undo the macOS settings a rebuild moved | `haus revert-settings` ([below](#roll-back-a-generation)) | | Stop using tiling, the bar, the launcher, the shelf | [Switch off a room](#switch-off-a-room) | | Remove the managed system, keep Nix | [Take down the system layer](#take-down-the-system-layer) | | Remove Nix itself | [Remove Determinate Nix](#remove-determinate-nix) | `~/.config/nix` is *your* machine described in text, not generated state. Archive it first; a git remote is better, if it holds no secrets. ```sh cp -R ~/.config/nix ~/.config/nix.archive ``` ## Roll back a generation [#roll-back-a-generation] Every successful switch makes a generation, and stepping back to one takes seconds: ```sh haus generations # what you can go back to haus rollback # the previous one haus rollback # a specific one ``` That restores everything Nix wrote: packages, agents, shell config, paths. It does **not** touch macOS's own preferences, which a rebuild sets imperatively: the Dock, Finder, the keyboard, the menu bar, file associations. Those have their own pair of commands: ```sh haus capture # snapshot the live settings before a risky change haus revert-settings # put the last snapshot back, byte for byte haus revert-settings list # every snapshot you've taken ``` If you never captured one, System Settings is the way back. **The wallpaper is the sharpest case.** `haus.wallpaper.style` defaults to `minimal`, so the desktop is stamped at every activation and macOS keeps no record of the picture that was there before. Rolling back doesn't bring yours back: re-pick it in System Settings **and** set `haus.wallpaper.style = "none"`, or the next rebuild stamps over it again. ## Switch off a room [#switch-off-a-room] Every room comes out independently, and the rest of the machine keeps working: ```nix { haus.prowl.enable = false; # AeroSpace tiling, the Caps-Lock leader, window bindings haus.sill.enable = false; # SketchyBar: the top bar, and the bottom one if you run it haus.pounce.enable = false; # the launcher daemon, ⌘Space, its Accessibility features haus.perch.enable = false; # the notch file shelf, see the residue note below haus.hush.enable = false; # the quiet switch: its bar pill, palette command and CLI haus.collar.enable = false; # Touch ID for sudo, back to the password prompt } ``` ```sh haus edit haus rebuild ``` The native menu bar comes back on its own: haus rewrites that key at every activation, so switching sill off is enough. Caps Lock comes back too, but on the next **restart**: the remap is a `hidutil` property that lasts until you reboot, so the session you rebuilt in keeps it. **Spotlight's ⌘Space** doesn't: pounce disables that shortcut with a one-way write and nothing turns it back on, so re-enable it in System Settings ▸ Keyboard ▸ Keyboard Shortcuts. hush leaves the reverse kind of trace: a Do Not Disturb chord bound onto a shortcut macOS ships disabled, which stays bound until you clear it. [Customize a desktop](/docs/haus/desktops/customizing#turning-rooms-off) has the full table, plus the middle ground where prowl keeps tiling but gives your keys back. ## Take down the system layer [#take-down-the-system-layer] This removes nix-darwin's system integration and leaves Nix installed for everything else that uses it. Run nix-darwin's own uninstaller: ```sh sudo nix --extra-experimental-features "nix-command flakes" \ run nix-darwin#darwin-uninstaller ``` If that can't run, try the locally installed copy: `sudo darwin-uninstaller`. **Read the plan before confirming.** It removes the managed system links, static files and launchd services, then restores the Nix daemon the installer supplied. Restart the Mac when it finishes. Your config isn't made useless by this. Keep it if you might come back, or move it out of the active path with `mv ~/.config/nix ~/.config/nix.archive`. ### Put your own dotfiles back [#put-your-own-dotfiles-back] Home Manager preserves a conflicting file with a `.backup` suffix rather than clobbering it, so the file you had before the install is usually still there: ```sh find "$HOME" -maxdepth 5 -name '*.backup' -print ``` Typically `~/.zshrc.backup`, `~/.zshenv.backup`, `~/.config/starship.toml.backup`, `~/.config/git/config.backup`, plus some inside managed directories. Inspect each one, confirm the Nix-managed target is gone, then move it back by hand. One exception to hunting for: zellij's `config.kdl.backup` is cleared on every rebuild by design, so it won't be there. Don't restore blindly. A backup is the version that existed at the *first* conflict, which may be years older than what you'd want today. ### Apps stay yours [#apps-stay-yours] haus never removes an app just because it left your config: `haus.homebrew.cleanup` is `"none"`. Compare what you declared against what Homebrew actually owns, and remove only what you no longer want: ```sh brew list --formula brew list --cask brew uninstall brew uninstall --cask # add --zap to take its data too ``` ### Pounce installed on its own [#pounce-installed-on-its-own] If you installed the launcher through Homebrew rather than as a haus room (removing the *room* is just `haus.pounce.enable = false` above): ```sh brew services stop pounce brew uninstall pounce brew untap hausfold/tap brew untap nebelhaus/tap # only if you installed before the org was renamed ``` Homebrew keys a tap by its directory, so an install that predates the rename leaves the old one behind unless you untap it by its old name. Your own commands and config live in `~/.config/pounce`; keep or archive that separately, and remove Pounce from **System Settings ▸ Privacy & Security ▸ Accessibility** if it's still listed. ## Remove Determinate Nix [#remove-determinate-nix] Removing Nix affects **every** Nix-managed project and tool on the Mac, not just haus. The Determinate installer ships its own uninstaller. Read the plan before confirming, and restart when it's done: ```sh sudo /nix/nix-installer uninstall ``` If it fails, follow Determinate's current macOS uninstall guide rather than deleting pieces of `/nix` by hand. ## What to check afterwards [#what-to-check-afterwards] No uninstaller can promise to reconstruct every preference that existed before an install. **`haus revert-settings` is the real answer if you captured a snapshot**; this list is for when you didn't: * the Dock's autohide, position, size and animations * key repeat, press-and-hold, and the trackpad's tap-to-click and three-finger drag * Finder's extensions, hidden files, path bar and view style * hot corners * the native menu bar and its clock, and Caps Lock * Spotlight's ⌘Space, which never comes back on its own, and the Do Not Disturb chord hush bound, which stays bound * the wallpaper * where screenshots land and in what format, and the screen-lock delay * alert volume and the startup chime (that one is firmware, and survives a reinstall) * your keyboard input sources: haus declares them exhaustively, so layouts you never listed were switched off * the application firewall * file associations, if `haus.hearth.hijackFileAssociations` was on * the launcher's Accessibility grant, and Touch ID for `sudo`, including the passwordless-`sudo` rule for rebuilds, which is the one with a security consequence The installer takes an APFS snapshot before it starts, but macOS purges local snapshots within about a day, so by the time you're reading this it has almost certainly gone. Treat it as luck, not as a plan. Then confirm what's actually gone: ```sh command -v haus || echo "haus is no longer on PATH" command -v darwin-rebuild || echo "darwin-rebuild is no longer on PATH" launchctl list | grep -Ei 'hausfold|nebelhaus|pounce|aerospace|sketchybar|sill|hush|sleepwatcher' ``` That pattern matters: haus runs more agents than the obvious three, under three different label prefixes, and a narrower grep reports a clean machine while the bottom bar and the hush watcher are still loaded. A surviving AeroSpace or SketchyBar process isn't automatically residue: both are Homebrew installs that haus only *launches*, so `brew uninstall` is what actually removes them. And a few things nothing has a removal path for: an activation step copied the shelf app into place, and state directories are written as the machine runs, so no uninstaller takes either away: ```sh ls ~/.local/state/haus ~/.local/state/nebelhaus ~/.local/state/pounce ~/.config/perch /Applications/Perch.app ``` ## If something stays behind [#if-something-stays-behind] A departure that doesn't complete is a bug worth reporting. [Open an issue](https://github.com/hausfold/haus/issues) with your Mac model and macOS version, whether Nix existed before haus, the path you followed, the exact command and its output, and what remained. Strip tokens, keys and personal files out of anything you paste. # The haus CLI (/docs/haus/reference/haus) `haus` is the command that drives your machine after install: it wraps the Nix build and the activation you would otherwise run by hand, and joins your `PATH` with the first successful switch. Run it with no argument and you get `haus status`. For the day-to-day shape of it, see [Keeping it current](/docs/haus/keeping-it-current). ## Commands [#commands] | Command | What it does | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `haus rebuild` | Build, then switch. Your everyday apply; a failed build never touches the running system. | | `haus update` | Move the haus pin in `~/.config/nix/flake.lock` forward, upgrade the family's Homebrew apps, then rebuild. | | `haus rollback [N]` | Return to the previous generation, or to generation `N`. | | `haus generations` | List the generations you can roll back to. | | `haus status` | The current generation, and how stale the pinned haus is. | | `haus edit` | Open your host file in `$EDITOR`. | | `haus set …` | Write a machine override, type-check it, rebuild once. | | `haus set` | With no arguments: search every option this machine has, then pick or type the value. | | `haus get [path]` | Print one declared value; with no path, list your overrides. | | `haus unset …` | Set nullable options to `null`, then rebuild once. | | `haus reset …` | Drop overrides and inherit the value underneath again. | | `haus plan` | What the next rebuild would change, read-only. | | `haus diff` | What the configuration you're *running* set, versus what macOS actually has right now. | | `haus capture [cat…]` | Turn this Mac's current settings into config lines *and* a snapshot. | | `haus revert-settings [snapshot\|list]` | Put a captured snapshot back. | | `haus options` | Refresh `hosts//options.nix` (every `haus.*` option at its default, described and commented out) from the build this machine is running. | | `haus doctor` | Health check: Nix, the Xcode CLT, the login agents, cask drift, Pounce's Accessibility grant, your secrets, agent readiness. | | `haus btm` | On macOS Tahoe and later, check whether Background Task Management is blocking the login agents. | | `haus tour [reset]` | Start the guided tour in the bar, or re-arm its first-run hint. | ## Changing a setting without opening an editor [#changing-a-setting-without-opening-an-editor] ```sh haus set theme.accent teal haus get theme.accent # teal haus reset theme.accent # inherit the desktop's value again ``` **Only `haus.*` paths are writable this way.** The prefix itself is optional: `theme.accent` and `haus.theme.accent` are the same path, and so is the old `nebelhaus.` spelling, which still resolves everywhere with a warning on rebuild. But a raw `system.defaults.*` or `homebrew.*` path, or an option that doesn't exist, fails before any file is written. Those belong in your host file, via `haus edit`. Values take the obvious shell form for strings and JSON for booleans, numbers, lists and attribute sets. `set` accepts as many pairs as you like and applies them in **one** rebuild: ```sh haus set theme.flavor latte theme.systemAppearance flavor ``` That matters when one intent spans two options (light mode is haus's palette *and* macOS's own appearance), because two calls would be two rebuilds with the machine sitting half-switched in between. Multiple pairs are all-or-nothing: one rejected value rolls the whole call back. `unset` and `reset` take lists for the same reason. The two undo verbs differ in one place. `reset` deletes the override, so whatever your host file or desktop says underneath becomes effective again; a path that had no override is reported and skipped rather than fatal. `unset` writes `null` unconditionally, so it succeeds only where the option's type admits `null`. ### When you don't know what it's called [#when-you-dont-know-what-its-called] Run `haus set` with no arguments and it asks instead: ```sh haus set ``` The first prompt is a search over every option this machine's pinned haus has: the path and a line of what it does, both searchable, so `light mode` finds `theme.flavor` as readily as `flavor` does. The second prompt is shaped by the option's own type: a closed set of values (an enum, a boolean) becomes a list with today's value under the cursor, and anything else is a text box holding the current default. Then it confirms, and hands the pair to the same `haus set` you would have typed: same type-check, same rollback, same single rebuild. Tab completion knows the same list: `haus set `, and likewise for `get`, `unset` and `reset`. Both read a static catalogue installed with your build, so neither one evaluates your configuration to answer; they describe the revision you have pinned, and `haus update` moves them with it. ### One key inside an option [#one-key-inside-an-option] A path may go *inside* an option rather than merely up to it: ```sh haus set sill.items.aiUsage true # one pill, not the whole bar haus set displays.internal.uiScale larger-text ``` **Prefer the leaf.** Overrides are written with `lib.mkForce`, so naming the enclosing set (`haus set sill.items '{"aiUsage":true}'`) forces the *whole* set, and every pill you didn't name falls back to its own default. A misspelled sub-option is refused before anything is written. Keys you invent yourself (a display's UUID, say) are taken as given, and what sits under them is checked instead. A path and one of its ancestors can't both hold an override: the second is refused, naming the first and the `haus reset` that clears it. ### Where the setting lives [#where-the-setting-lives] `haus set` writes an ordinary Nix module to `~/.config/nix/hosts//settings/.nix` and stages it: a git-backed flake ignores untracked files, so staging is what makes it take effect. The file *is* the setting: no database beside it, safe to read, edit or commit. Neither `set` nor `reset` commits or pushes for you. The [launcher](/docs/haus/rooms/launcher)'s **Haus Settings** command is the same mechanism behind three intent-sized buttons: *Make text bigger*, *Switch to light mode*, *High contrast on*. The palette keeps no settings of its own; it calls `haus set`. ## Looking before you leap [#looking-before-you-leap] `haus rollback` rewinds everything Nix owns, atomically. It does **not** rewind macOS's own preferences: those are written imperatively during activation, into `com.apple.dock`, Finder, `NSGlobalDomain` and friends, and no generation holds them. Four commands close that gap: ```sh haus plan # what the next rebuild would change, before it changes it haus diff # what the running config set vs what macOS has right now haus capture # snapshot the settings a rebuild is about to move haus revert-settings # put that snapshot back ``` `capture` before a risky change and `revert-settings` after is the macOS-defaults equivalent of a generation rollback. It defaults to the Dock, keyboard and Finder; name a plist domain to reach anything else. Both read the built activation script itself, and both probe macOS live for the accessibility keys it accepts silently and then ignores, so neither is trusting a plist or a hand-kept list. `plan` reports five things, and the middle one is the one people expect to be missing: packages, macOS settings, **the files written into your home** (with the `onChange` hooks each would fire), launchd jobs, and new casks. Most of what a desktop changes is neither a package nor a settings key: switching a bar pill on moves a config file and nothing else, which no closure diff would show. The handful of files written by an activation script rather than linked (zellij's `config.kdl` is the one you'll meet) stay outside that set and won't appear. `haus` always evaluates `~/.config/nix` unless you tell it otherwise. Run `haus plan` from a linked git worktree of that config (an agent lane, say) and it previews the config on `main`, not the branch you're looking at. It warns and names both paths; point it at the tree you mean: ```sh HAUS_CONSUMER="$(git rev-parse --show-toplevel)" haus plan ``` ## The one rebuild it refuses [#the-one-rebuild-it-refuses] If your host file writes `system.defaults.universalaccess.*` directly, `haus rebuild` refuses to run whenever the app it runs under lacks Full Disk Access: that write would abort activation partway and take every background service down with it. The grant belongs to the app, not to you, so this catches a plain terminal nobody has granted just as surely as an agent pane, and lets an agent pane inside a granted terminal through. Sidestep it with `haus.accessibility.*`, which reaches every key in that domain macOS actually honours (all seven, which since August 2026 is every key nix-darwin types there) with a guarded write. Otherwise run the command from an app that holds the grant, or set `HAUS_FDA_ANYWAY=1` when you know better. `haus plan` says which grant a rebuild wants before it runs. [Changing your Mac with an agent](/docs/haus/rooms/agent-rebuilds) has the detail. ## The other CLIs [#the-other-clis] `haus` is the only command an end user needs. Two more arrive with the rooms that want them: `haus.ai.enable` brings **`holt`** ([the AI room](/docs/haus/rooms/ai)), agent worktrees for any repo, and `haus.developer.enable` brings **`zscratch`**, for feel-testing a zellij edit without a rebuild. # haus.* options (/docs/haus/reference/options) {/* GENERATED FILE — do not edit by hand. Rendered from haus's own module system by scripts/gen-options.mjs. To change an option's description, edit its declaration in haus and regenerate: node scripts/gen-options.mjs --haus /path/to/haus CI re-renders this and fails if it differs, so a hand edit here is guaranteed to be reverted. */} These are the `haus.*` options you set in your host file at `~/.config/nix/hosts//default.nix`. Everything here is optional unless noted; the defaults are a complete, working system. The page is grouped by **room** — the same rooms the sidebar is organised around — and each room lists the `haus.*` namespaces it owns. A room can own more than one: the Bar room is `haus.sill` (its own bar) *and* `haus.menuBar` (macOS's). Apply changes with `haus rebuild`. Each option lists its **type** and **default** under its name, and links to the file that declares it. ## Apps [#apps] The apps a finished machine has: the curated picks, the packs that switch a whole set on in one line, the file types they claim, App Store policy, and what a rebuild does to anything you installed by hand. The list they all land in is `haus.roster`, a shared surface below. ### haus.appStore [#hausappstore] Whether a rebuild may install the roster's `appStoreId` entries. Off by default: it reaches the network and acts on your Apple Account, and it can never be complete — `mas` cannot sign in, and cannot buy a paid app. #### `haus.appStore.install` [#hausappstoreinstall] `boolean` · default `false` Install roster entries that set `appStoreId` from the Mac App Store during activation, skipping any already installed. Off by default: this reaches the network and acts on your Apple ID, which shouldn't happen as a side effect of turning on a window manager. It also can't be complete — `mas` cannot sign in (do that once in App Store.app) and cannot make a first-time PURCHASE, so a paid app you don't already own is reported and skipped rather than installed. Deliberately NOT nix-darwin's `homebrew.masApps`: that runs `mas install` through `brew bundle` as your user, and since macOS 13 the App Store install path requires root — so it stops for a password prompt that a rebuild has no terminal to show, and the rebuild hangs. The activation step this option enables is already running as root, so it neither prompts nor wedges. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . ### haus.apps [#hausapps] The apps haus picks for you, the saved collections you can switch on in one line, and the file types they claim — the ones a finished machine has rather than the ones a room needs to work. Each is one switch you can turn off; what it installs is a roster entry like any other, so you can retune or replace it by app id. #### `haus.apps.packs.writing.enable` [#hausappspackswritingenable] `boolean` · default `false` Install the **writing** collection: Obsidian, Zotero, Anki and calibre — a Mac that reads and writes rather than compiles. These arrive as ordinary roster entries at `mkDefault`, so anything you say about one of them in your own host file wins per FIELD and the rest of the entry survives: haus.roster.zotero.key = "y"; # a letter of your own haus.roster.obsidian.appId = "…"; # osascript -e 'id of app "Obsidian"' Two of them claim a leader letter (`o`, `l`, `k`) and none claims a workspace — a workspace names its own members, so give one to Obsidian in your host with `haus.workspaces`. The file is `modules/apps/packs/writing.nix`, and it is readable data: four casks and the keys to reach them. Example: ```nix true ``` Declared in [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix) . #### `haus.apps.videoPlayer.claimFileTypes` [#hausappsvideoplayerclaimfiletypes] `boolean` · default `true` Make IINA the default handler for the everyday video extensions — mp4, m4v, mov, mpg, mpeg, mkv, webm, avi, wmv, flv, 3gp, ogv, vob — so double-clicking a video opens IINA instead of QuickTime Player, TV or a browser. Ignored unless the player is installed. A short list on purpose: it covers what you actually double-click, not everything IINA can decode. Dead, professional and DRM'd containers (qt, divx, asf, f4v, 3g2, ogm, rm, rmvb, mxf, dv, …) are left alone — they still play via Open With, they just don't get the default, and every extension haus claims is a binding it re-asserts on every rebuild. Video only. Audio (mp3, flac, m4a, wav, …), `.gif` and playlists keep whatever owns them today, since "open videos in IINA" rarely means "and my music library too". The transport-stream extensions `.ts`, `.mts` and `.m2ts` are excluded too: on a developer's machine they are TypeScript far more often than video, and `haus.hearth.hijackFileAssociations` claims them for the editor. Claiming them here as well made macOS stop and ask which app should win on every single rebuild, because `.mts` and `.m2ts` share one UTI. This sets the USER default (via `duti`) — the same record Finder's Get Info ▸ Change All writes, so it is undoable by hand. Set false to install the app and leave every association alone. Declared in [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix) . #### `haus.apps.videoPlayer.enable` [#hausappsvideoplayerenable] `boolean` · default `false` Install IINA — haus's video player — as the roster entry `iina`. A nixpkgs build, so it lands in \~/Applications/Home Manager Apps rather than /Applications. The nebelhaus desktop turns this on, because macOS ships QuickTime Player, which refuses most of what you actually double-click (mkv, webm, and anything not in Apple's codec list) — so "a video player that plays videos" is part of what that desktop considers a finished machine. The bare layer installs nothing you didn't ask for. Set false and nothing is installed or rebound — bring your own player via the pounce "Install App" palette command or a roster entry. Once on it is a roster entry like any other: give it a leader letter with `haus.roster.iina.key`, or pin a different build with `haus.roster.iina.package`. Declared in [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix) . ### haus.homebrew [#haushomebrew] How rebuilds treat Homebrew packages you did not declare. #### `haus.homebrew.autoUpdate` [#haushomebrewautoupdate] `boolean` · default `false` Run `brew update` before activating the Homebrew step on every rebuild. Off by default — reproducible rebuilds shouldn't silently pull newer formulae. Turn on if you want brew to track upstream. Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.homebrew.cleanup` [#haushomebrewcleanup] `one of "none", "uninstall", "zap"` · default `"none"` How `darwin-rebuild switch` treats Homebrew casks/brews that are installed but NOT declared anywhere in your config. * "none" (default, safe): leave undeclared formulae/casks alone. haus never deletes apps you installed yourself. * "uninstall": remove undeclared formulae/casks (keeps their data). * "zap": remove undeclared formulae/casks AND their app data. Fully declarative, but a stray cask you forgot to list is deleted — with no backup — on the very next rebuild. Only choose this once every app you keep is declared (bootstrap can adopt your current casks). Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.homebrew.upgrade` [#haushomebrewupgrade] `boolean` · default `false` Upgrade outdated Homebrew packages on every rebuild. Off by default for the same reproducibility reason as autoUpdate. Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ## Appearance [#appearance] How the machine looks: the palette and its accent, the wallpaper, the fonts, and the macOS surfaces that follow them — motion, screenshots, sound and the accessibility keys. The interface scale every room reads is `haus.ui`, a shared surface below. ### haus.appearance [#hausappearance] The Appearance room's own profile — one named answer to a whole-machine question, where the groups below are the individual dials. `largePrint` sets the interface scale, the high-contrast palette, macOS's own contrast lift and the screen's scaled resolution together, each as a default you can still pin by hand. #### `haus.appearance.largePrint` [#hausappearancelargeprint] `boolean` · default `false` Make everything haus controls bigger and sharper, in one line. Deliberately NARROW: it is about seeing, not about who you are, so it says nothing about which rooms you run — set it in any desktop, or in your host on top of one. It moves four things, each as a DEFAULT, so pinning any single one by hand still wins — with one asymmetry worth knowing: a DESKTOP that sets one of the four beats this profile even when the profile was switched on in your host, because the desktop's values sit above a room's defaults in the priority ladder. Set the value itself in your host to settle it: haus.ui.scale = 1.4 the terminal font (19 → 27 pt), the whole command palette, the bar's type (to its ceiling, see below), Dock icons 48 → 67, Finder's large sidebar rows, prowl's gaps haus.theme.contrast = "high" body text 11.3:1 → 19.9:1 against the background, across every tool haus colours. Measured in nebelung's CI. haus.accessibility.increaseContrast = true the same lift for NATIVE macOS apps, which the palette cannot reach. FDA-gated at the option, so it sharpens the result where it applies and is skipped where it doesn't. haus.displays.main.uiScale = "larger-text" one step of the screen's scaled resolution toward larger text. The only line here that reaches apps haus has never heard of, because it changes what a point MEANS. `main` rather than `internal` on purpose: on a laptop they are the same panel, but on a Mac mini or a clamshelled MacBook `internal` matches nothing — so keying on the built-in panel would quietly do nothing on the desk setup most likely to need this. Name a specific monitor by UUID in your host file for per-display control; `hausdisp list` prints them. What it does NOT move, stated here because a large-print machine that quietly under-delivers is worse than one that says where it stops: * macOS's own text-size setting. `universalaccess`'s FontSizeCategory key stores a value and posts no change notification, so apps never re-read it. Display scaling is the lever that works. * Third-party apps' own font settings. Nothing outside haus follows `ui.scale` — they follow the display, which is why the line above matters. * The perch shelf, which sizes itself from the screen because it hangs off the notch. Scaling the display shrinks its width in points by exactly the factor that makes a point bigger. * The menu bar's HEIGHT. Its type grows to a 1.25x ceiling and stops: 36pt of bar with 28pt pills is what keeps them inside the 32pt band macOS's own hover-reveal covers, and that band is macOS's. The lever that does move it is the display scaling above. * A more legible FONT FAMILY, which is a choice rather than a limit. A typeface is taste and a legibility profile should not decide yours; Atkynson Mono is Atkinson Hyperlegible's monospaced sibling, drawn by the Braille Institute for exactly this problem: haus.fonts.mono.packageName = "nerd-fonts.atkynson-mono"; haus.fonts.mono.name = "AtkynsonMono Nerd Font"; * Light mode, if it reads better for you: `haus.theme.flavor = "latte"`. Example: ```nix true ``` Declared in [`modules/appearance/options.nix`](https://github.com/hausfold/haus/blob/main/modules/appearance/options.nix) . ### haus.theme [#haustheme] Colour: the palette's flavour and contrast, the accent every themed tool spends, and whether macOS's own Light/Dark follows it. #### `haus.theme.accent` [#hausthemeaccent] `one of "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `"mauve"` The accent colour, by Catppuccin name (the Nebelung palette is a grey-tinted Catppuccin, so the fourteen names are the same in both flavors — the hue you pick follows haus.theme.flavor). It recolours the tools nebelhaus injects colours into — lazygit, fzf, yazi (including glow-rendered Markdown headings), and the Zen browser — via the matching Nebelung per-accent ports. perch follows it too, and is the one surface handed the NAME rather than a hex: the shelf resolves it against whichever half of its dark/light pair macOS is showing, so the ember under the notch and a pinned tile wear this accent in both polarities from one key. Left at perch's default it accents with its own mark green. Three more things follow it: the generated desktop (the bloom behind the mark in `minimal`, and the whole sweep in `bold` — see haus.wallpaper.style), any roster app whose Nebelung port ships a per-accent matrix (zed, gh-dash, mpv), placed by haus.theme.ports, and the bar's far-left logo pill. Those ports name the theme file after the accent, so changing the accent renames the file the app's own `theme` key points at — re-pick it in the app, or it falls back to stock. The bar is the newest and the narrowest of the three: `haus.sill.logo` is the ONLY pill that follows this option. Every other colour on the bar is a fixed palette key, and the palette itself doesn't move — so a machine that changes its accent sees exactly one pill change hue, unless `haus.sill.logo.color` names one of its own. Honest scope: this moves the accent on those tools, NOT literally everything. Single-file dotfiles that bake the palette at their own theme slot (ghostty, starship, tmux, bat, zellij, …) keep their built-in colour and don't follow this option. The base palette stays the same Nebelung grey either way — only the accent hue changes. Zen means Zen's own UI, and the web is a separate story. haus places the Nebelung userChrome/userContent pair, but userContent only styles `about:` pages — github.com and youtube.com are themed by the Stylus extension, whose Catppuccin-derived styles carry their OWN `accentColor` var (default mauve) inside the extension's storage, where no stylesheet can reach it. Declare `haus.zen.extensions.stylus` and haus stamps that var with this accent and tells you, once, when there's a new bundle to import; the import itself stays a click, because Stylus has no file interface. Until you make it, the web keeps the accent you last imported. Both halves of that are pinned by the `accent-reach` flake check, which fingerprints every surface under three accents and fails if one starts or stops following the accent without anyone deciding it should. Example: ```nix "sapphire" ``` Declared in [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix) . #### `haus.theme.contrast` [#hausthemecontrast] `one of "normal", "high"` · default `"normal"` How far the interface separates from its background. "high" swaps in the Nebelung high-contrast palette: the same hues and the same accents, with the neutral ramp pulled apart in OKLCH so text and background separate further at every step. Measured rather than eyeballed — body text goes from 11.3:1 to 19.9:1 against the base, clearing WCAG AAA (nebelung's own CI asserts it). Composes with `flavor`, and the boost is tuned per flavor rather than shared: light mode has far less room above its background before the ramp clips to white, so latte goes 7.0:1 → 9.9:1 where mocha goes 11.3 → 19.9. Both keep all twelve ramp steps distinct, which is the property nebelung's tests actually assert. Honest scope. This recolours what haus injects colours into: Ghostty, bat, delta, lsd, yazi, zellij, glow, starship, lazygit, the bar, pounce and perch (at runtime, via \~/.config/\{pounce,perch}/themes/ — and unlike `flavor`, contrast reaches both on BOTH halves of their light/dark pair), Zen and Obsidian. It does NOT reach: * macOS itself. For system-wide contrast see haus.accessibility.increaseContrast — a separate, FDA-gated setting. The two are complementary, and a genuinely high-contrast machine wants both. Example: ```nix "high" ``` Declared in [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix) . #### `haus.theme.flavor` [#hausthemeflavor] `one of "mocha", "latte"` · default `"mocha"` Light or dark. "mocha" (the default) is the dark half of Nebelung, which is what haus has always shipped; "latte" is light mode. Not an inversion of the dark palette — a different SOURCE palette. Nebelung is "Catppuccin with the blue stripped out", and those rules say nothing about dark, so they apply to Catppuccin Latte just as well: same warm-grey neutral ramp, same calmed accents, the other polarity. Light mode lands at 7.0:1 for body text on its own, so it's legible before you reach for contrast = "high" (which takes it to 9.9:1). It composes with `contrast`: the two axes give four palettes, and nebelung's CI measures each one's contrast ratio rather than eyeballing it. Honest scope, in two parts. What follows it: every tool haus injects colours into or points at a rendered theme — Ghostty, bat, delta, lsd, yazi, fzf, glow, starship, lazygit, zellij, opencode, the bar, Zen and Obsidian, plus helix whenever it is the editor `haus.hearth.editorName` selects (Nebelung has a port for helix and none for the alternatives). These are genuinely re-rendered for the flavor, not recoloured in place: whiskers takes different branches for a light flavor (terminal ANSI 0/7/8/15 swap, Zen switches its prefers-color-scheme block, delta sets `light = true`). What does NOT follow it: * pounce and perch, by default. Both read their palette at runtime and can pick per polarity, so haus.pounce.followSystemAppearance and haus.perch.followSystemAppearance (default true) hand that choice to macOS Light/Dark instead: haus installs every rendered variant into \~/.config/\{pounce,perch}/themes/ and writes the dark/light PAIR at your `contrast`. Set either option false to pin that app to this flavor like everything else. * macOS's own Light/Dark appearance, unless you opt in with haus.theme.systemAppearance = "flavor". Left at its default haus does not touch system appearance in either direction, so latte on a dark macOS looks half-done and that half is yours — except in pounce and perch, which read the appearance themselves. * three of the six desktops (haus.wallpaper.style). The hand-made "orbits", "constellation" and "flow" have the dark palette baked into their pixels; "bold" is generated but follows theme.accent rather than the flavor. "minimal" DOES follow it, in every part — field, mark, glow and debug band. Example: ```nix "latte" ``` Declared in [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix) . #### `haus.theme.ports.enable` [#hausthemeportsenable] `boolean` · default `false` Theme the apps in your roster (`haus.roster`) that Nebelung ships a port for, without wiring each one by hand. haus already themes every tool it installs itself — the shell, the terminal, the git stack, Zen, Obsidian. This covers the other direction: an app YOU added to the roster that Nebelung happens to have a theme for. Add `zed`, `warp` or `xcode` to `haus.roster` and its Nebelung theme lands where that app looks for themes, in the flavor and contrast you selected, following them on every rebuild. Matching is by roster id, so the entry has to be named after the port (`zed`, not `zed-editor`). Honest scope, and it is the whole point of the option: this drops the theme FILE. Whether that alone makes the theme *active* is the app's choice, not ours, and Nebelung records which is which per port. Ghostty reads a config key we own, so it just works. Xcode, Warp, OBS and friends offer no file interface for picking a theme — the file is put where they look, and the one click that selects it stays yours. `haus doctor` lists exactly which apps are waiting on that click, so the difference is visible rather than something you discover months later. Ports whose install is a merge into an existing config file, or that need a compile step first, are reported but never written: silently half-applying someone's config is worse than saying so. Declared in [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix) . #### `haus.theme.systemAppearance` [#hausthemesystemappearance] `one of "unmanaged", "flavor", "light", "dark"` · default `"unmanaged"` Whether haus also sets macOS's OWN Light/Dark appearance — the one in System Settings ▸ Appearance, which paints Finder, the menu bar and every native app haus can't reach. unmanaged (default) leave it alone, in both directions. Your Mac's appearance stays yours; nothing about a rebuild moves it. flavor follow haus.theme.flavor — latte sets Light, mocha sets Dark. This is the one that makes light mode complete rather than half-done. light pin Light, whatever the flavor is. dark pin Dark, whatever the flavor is. Default "unmanaged" on purpose: a managed default would silently revert an appearance you picked in System Settings on the next rebuild, which is a worse surprise than a half-light machine. How it is applied, and why it is not a `system.defaults` key. Measured on macOS 26.6 (2026-08-08), NOT recalled from docs: `NSGlobalDomain.AppleInterfaceStyle` is INERT in both directions. Writing "Dark" from a light session does nothing; deleting the key from a dark one does nothing; `activateSettings -u` does not help; a process launched fresh afterwards still reports the old appearance, and no AppleInterfaceThemeChangedNotification is posted. That key is a mirror the appearance system writes, not a lever. So haus drives appearance through System Events (AppleScript) at each home-manager activation, which does flip it live in \~0.3s — and confirms the result with `hausax` (AppKit's effective appearance), never by reading the key back. Reachability, the same shape as haus.accessibility.increaseContrast: driving System Events needs an Automation grant for whichever app runs the rebuild (System Settings ▸ Privacy & Security ▸ Automation). Without it macOS refuses, the rebuild says so in a named warning and carries on — the appearance just doesn't move, and nothing else is affected. One more thing macOS can undo: System Settings ▸ Appearance ▸ **Auto** switches polarity on its own schedule. haus sets the appearance at rebuild time and does not fight it afterwards, so on an Auto machine this option holds only until the next scheduled switch. Pick Light or Dark there if you want it to stick. Interaction worth knowing: haus.\{pounce,perch}.followSystemAppearance hand polarity to macOS. Set this to "flavor" and macOS's polarity is in turn haus's, so those two end up following `flavor` transitively — which is usually what you wanted, but it does mean `followSystemAppearance` stops being an independent axis on this machine. Example: ```nix "flavor" ``` Declared in [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix) . ### haus.wallpaper [#hauswallpaper] The desktop behind everything. `minimal` is generated on this machine — a flat field at whatever depth you pick out of the palette, the haus mark ⌂ at its centre, a bloom in your accent, and enough grain that none of it bands. The other looks are the hand-made Nebelung ones. #### `haus.wallpaper.background` [#hauswallpaperbackground] `null or string matching the pattern #[0-9a-fA-F]{6}` · default `null` The field colour, as a literal hex — an escape hatch out of the palette for a desktop that wants a colour Nebelung doesn't have. Null (the default) resolves it from haus.wallpaper.depth against the flavour's ladder, which is the arrangement that keeps following the theme. Setting this pins the field and `depth` stops meaning anything. Example: ```nix "#0b0b0e" ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.debug.enable` [#hauswallpaperdebugenable] `boolean` · default `false` Print this machine's lock edges in the bottom-left corner — which revision of each family repo the running system was built from. It is a detail rather than a readout. It sits at exactly the inset a tiled window covers (see `debug.inset`), so it is invisible the moment anything is on screen and only ever surfaces on a bare desktop; it is set small, dim and wide-tracked; and it names four repos rather than everything the flake pins. Off by default. Example: ```nix true ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.debug.inputs` [#hauswallpaperdebuginputs] `list of string` · default `[ "self" "nebelung" "pounce" "perch" "holt" ]` Which flake inputs the band names, in the order it prints them. `self` is haus itself and prints as `haus`; every other entry is an input name out of haus's own flake, and one that isn't there is skipped rather than failing the build. The default is the family chain, which is the one thing a rev is worth knowing on a desktop: it's what `bench status` calls the lock edges, and the answer to "is this machine running the branch I just merged". Example: ```nix [ "self" "nixpkgs" ] ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.debug.inset` [#hauswallpaperdebuginset] `null or (unsigned integer, meaning >=0)` · default `null` How far in from the bottom-left corner the band sits, in PICTURE PIXELS. Null derives it from the tiling gaps — the widest outer reservation any attached display could be using (../lib/gaps.nix, the same numbers prowl writes into aerospace.toml), doubled for a Retina display's two pixels per point. That lands the band exactly at a tiled window's bottom-left corner, which is the whole trick: the text is under the windows, not beside them, so a tiled desktop hides it completely and a bare one doesn't. Set a number if your display isn't 2× — or if you'd rather see it. Example: ```nix 96 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.debug.size` [#hauswallpaperdebugsize] `integer or floating point number between 0.002 and 0.1 (both inclusive)` · default `0.011` The band's type size, as a fraction of the picture's short edge. It is set in haus.fonts.mono, so the desktop and the terminal in front of it are the same typeface. Example: ```nix 0.02 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.depth` [#hauswallpaperdepth] `integer between 0 and 5 (both inclusive)` · default `1` How far in from the palette's outermost tone the field sits — the answer to "I want it blacker" without anyone having to name a colour. Nebelung's background tones are a ladder of six, ordered here from the end nearest the polarity's extreme inwards, so the SAME number means the same distance from black in a dark flavour and from white in a light one: depth dark (mocha) light (latte) 0 crust #121212 base #f1f1f1 1 mantle #191919 mantle #e9e9e9 ← default 2 base #202020 crust #e0e0e0 3 surface0 #343434 surface0 #d0d0d0 4 surface1 #494949 surface1 #c0c0c0 5 surface2 #5c5c5c surface2 #b0b0b0 0 is as far out as the palette goes — our blackest black, our whitest white. The default of 1 lands exactly one rung inside that extreme in EITHER polarity, which is what keeps the desktop reading as material rather than as a hole cut in the screen while still being properly dark in a dark flavour — a full screen of `base` reads as a big terminal window, not as a wall behind one. The two columns are NOT symmetric, and the asymmetry is the palette's rather than a choice: mocha's canvas (`base`) sits at depth 2 because two tones are darker than it, while latte's canvas is the LIGHTEST tone it has, so it sits at depth 0. So the ONE number moves the two flavours in opposite directions relative to their canvas — the default puts a dark flavour one step BELOW the colour its terminal draws on (#191919) and a light one one step below the canvas too (#e9e9e9), which is the agreement worth having, since a full screen of near-white is the one field size where latte's canvas stops being comfortable. `depth = 0` is the way to match the terminal exactly in a light flavour; `depth = 2` is the way to match it in a dark one. Which flavour's column applies follows haus.theme.flavor, like every other themed surface. haus.wallpaper.background overrides the whole thing with a literal hex. Example: ```nix 0 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.glow.color` [#hauswallpaperglowcolor] `null or string matching the pattern #[0-9a-fA-F]{6}` · default `null` The colour the bloom tends towards at its centre. Null takes haus.theme.accent's hex, which is what makes the desktop change temperature with the accent without anyone wiring a second colour. Example: ```nix "#8db4f3" ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.glow.enable` [#hauswallpaperglowenable] `boolean` · default `true` A single broad bloom behind the mark, so the field reads as lit rather than as a fill. Subtle by construction — see `glow.strength`. Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.glow.spread` [#hauswallpaperglowspread] `integer or floating point number between 0.2 and 4.0 (both inclusive)` · default `1.15` The bloom's diameter, as a multiple of the picture's long edge. Above 1 its falloff runs off the edges and the field reads as evenly lit from the middle; below 1 it closes into a halo around the mark. Example: ```nix 0.6 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.glow.strength` [#hauswallpaperglowstrength] `integer between 0 and 100 (both inclusive)` · default `3` How much of the bloom is mixed into the field, as a percentage. Small numbers on purpose: at 3 the accent is a few levels of lift you'd struggle to name and would miss if it went. Past \~25 it stops being light on a wall and starts being a coloured wallpaper, which is a different desktop than this one. (Was 7 until it turned out to read as the field simply not being dark enough, rather than as a glow.) Example: ```nix 14 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.grain` [#hauswallpapergrain] `integer or floating point number between 0.0 and 0.1 (both inclusive)` · default `0.01` Film grain over the whole field, as a fraction of full scale — and the reason the glow doesn't band. This is dither, dressed as texture. A soft glow across two thousand pixels spends perhaps ten of the 256 levels an 8-bit PNG has, so it quantises into visible contour rings — the "steppy gradient" every hand-made wallpaper picks up on the way out of an image editor. Noise of a couple of levels, added BEFORE the render is reduced to 8 bits, breaks those contours into something the eye integrates back to smooth. 0.004 is enough to hide them; the default is comfortably past that. 0 turns it off. Do that only with `glow.enable = false` too — a glow on an ungrained field is exactly the picture this exists to prevent. Measured at the shipped defaults (3456x2234), since the effect is easier to state in numbers than to argue about — distinct colours, and what the PNG costs, noise being the one thing that doesn't compress: grain colours size 0 137 0.1 MB ← rings, visibly 0.004 193 1.1 MB ← the floor worth using 0.010 329 2.5 MB ← the default 0.020 625 4.2 MB Example: ```nix 0.0 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.mark.color` [#hauswallpapermarkcolor] `one of "muted", "ink", "accent", "spectrum"` · default `"spectrum"` What the mark is drawn in. muted the palette's overlay1 — present, not loud. The mark as it sits on hausfold.co untouched. ink the palette's text colour, for a mark meant to be read rather than noticed. accent haus.theme.accent's hex, flat. spectrum the whole family at once: a conic sweep through the six product accents — nebelung, holt, perch, trill, pounce, nebelhaus — clipped to the stroke. This is the ⌂ as it looks with a pointer on it on hausfold.co, held still. `spectrum` is the default, and follows the flavour like everything else: the six are the Nebelung pastels in a dark flavour and their darker counterparts in a light one, because a pastel sheen on a white wall is invisible. It is the loudest of the four on purpose — one small piece of colour is the whole of what this desktop says out loud, and it says the family rather than any one product. `muted` is the quiet way back, and `mark.opacity` turns the sweep down without leaving it. Example: ```nix "muted" ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.mark.enable` [#hauswallpapermarkenable] `boolean` · default `true` Draw the haus mark ⌂ at the centre. Off leaves the field, the glow and the grain — which is a perfectly good desktop, and the fastest way to get one flat colour that still isn't flat. Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.mark.opacity` [#hauswallpapermarkopacity] `integer or floating point number between 0.0 and 1.0 (both inclusive)` · default `1.0` The mark's opacity over the field. Worth reaching for with `spectrum` — the default, and the one colour here loud enough to want turning down. Example: ```nix 0.55 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.mark.rise` [#hauswallpapermarkrise] `integer or floating point number between -0.5 and 0.5 (both inclusive)` · default `0.0` How far above centre the mark sits, as a fraction of the picture's height. Optical centre is a little above geometric centre, and a bar along the top edge moves it further — a small positive number is the usual correction. Example: ```nix 0.06 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.mark.size` [#hauswallpapermarksize] `integer or floating point number between 0.01 and 0.9 (both inclusive)` · default `0.1` The mark's height, as a fraction of the picture's SHORT edge — so it keeps its proportion whatever `size` and whatever display. Example: ```nix 0.3 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.mark.weight` [#hauswallpapermarkweight] `integer or floating point number between 0.005 and 0.25 (both inclusive)` · default `0.09` Stroke width, as a fraction of the mark's own height. The mark's SHAPE is the real U+2302, traced off the outline hausfold.co renders, and the default weight is now the site's too: that glyph's stems are a tenth of its height, which is 0.094 here once the miter at the apex is counted, and 0.09 is that within a hair. So the desktop and the site draw the same mark, which is the agreement worth having when the two sit side by side. It used to default to 0.055 — a little under 60% of the glyph's own weight — on the grounds that a stem which reads right in a line of type is heavy drawn a foot wide on a wall. That is true of a mark filling the screen; it is not true of one at `mark.size`, where the lighter stroke reads as a hairline rather than as the ⌂. Go back to it if you want the outline to recede. Example: ```nix 0.055 ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.size` [#hauswallpapersize] `string matching the pattern [0-9]+x[0-9]+` · default `"3456x2234"` The pixel size `minimal` is rendered at, `WIDTHxHEIGHT`. Set it to your display's NATIVE pixel count and macOS has nothing left to do: the picture lands one image pixel per screen pixel, which is the only arrangement where the grain that keeps the glow smooth survives at the size it was dithered for. Anything else is resampled, and resampling is where a gradient that was clean in the file starts to look stepped. The default is the 16" MacBook Pro panel — the largest built-in Retina display, so a smaller one scales DOWN (soft, harmless) rather than up. `system_profiler SPDisplaysDataType` prints yours. Aspect matters as much as size: macOS fills the screen and crops the overflow, so a picture narrower than the display loses its top and bottom — which is where `debug` draws. On a display of a different shape, set this to that display's own numbers. Example: ```nix "3024x1964" ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . #### `haus.wallpaper.style` [#hauswallpaperstyle] `one of "none", "minimal", "orbits", "constellation", "flow", "bold"` · default `"none"` Which desktop this machine wears, set at each home-manager activation (osascript, every desktop on the current Space). minimal GENERATED here — a flat field in your palette, the haus mark ⌂ at its centre, and nothing else. The one haus-themed look, and the one every option below tunes. orbits hand-made Nebelung PNGs, the palette baked into their constellation pixels — they do not follow haus.theme.flavor. flow bold generated from haus.theme.accent alone (a diagonal accent→crust sweep), which predates `minimal`. The default is `none`, and it is a real value rather than an absence: nothing here runs and whatever wallpaper you already have stays exactly where it was. Replacing someone's desktop picture is the most visible thing this layer can do, so the bare room does not do it uninvited — a DESKTOP says which look it wants, and nebelhaus picks `minimal`. That is the second change of mind on this option, and the reasoning survives both: `minimal` was made the default so a desktop wouldn't ship looking like nothing in particular, which is still true of nebelhaus and is why its desktop sets it. What changed is that a desktop is now the thing making that choice, rather than every install of the layer. Every option below tunes `minimal`, and all of them keep their tuned values — the choice being opt-in is not a reason for the look to be worse once chosen. Example: ```nix "minimal" ``` Declared in [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix) . ### haus.fonts [#hausfonts] The terminal font. The bar keeps its own font at its own tuned sizes. #### `haus.fonts.mono.baseSize` [#hausfontsmonobasesize] `positive integer, meaning >0` · default `13` The terminal-font baseline, before `haus.ui.scale` multiplies it. The neutral room uses 13pt; nebelhaus selects 19pt in its desktop. This exists so a desktop can carry that tuned baseline WITHOUT breaking the scale relationship. Setting `size` directly pins an absolute number, which would make `haus.ui.scale` (and `haus.appearance.largePrint`, built on it) stop moving the terminal font at all — a silent regression, since everything else would still grow. Say the baseline here; say the exception with `size`. Example: ```nix 19 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.fonts.mono.name` [#hausfontsmononame] `string` · default `"JetBrainsMono Nerd Font Mono"` haus's type family, as Ghostty's `font-family` names it. It reaches the terminal AND the menu bar: every pill label and icon sill draws is in this family, at sizes of its own (see `haus.ui.scale`). The workspace-logo glyphs are the one exception — those are sketchybar-app-font, which sill installs itself. This should be a NERD FONT patched build: starship's prompt, lsd's icons, yazi previews and half the bar's icons draw with glyphs a stock font renders as tofu. If you change this, set `package` (or `packageName`) too — haus can only install a font it's been given, and it warns when you name a family without one. The name is taken verbatim, so a "… Nerd Font Mono" family is drawn in the bar as well: the bar mixes icon glyphs into its labels, which is the same reason the terminal wants a patched font. Example: ```nix "Berkeley Mono" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.fonts.mono.package` [#hausfontsmonopackage] `null or package` · default `null` The package providing `name`. null (the default) installs haus's own JetBrains Mono Nerd Font, which is what `name` defaults to. Set this whenever you change `name`, or the family simply won't exist on the machine and Ghostty will silently fall back — haus warns if it spots that combination. A shared desktop can't set this one — it needs `pkgs`, and a data-only desktop has no arguments. Use `packageName` there. Example: ```nix pkgs.nerd-fonts.fira-code ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.fonts.mono.packageName` [#hausfontsmonopackagename] `null or string` · default `null` The same thing as `package`, NAMED rather than evaluated: an attribute path into nixpkgs, so "nerd-fonts.fira-code" means `pkgs.nerd-fonts.fira-code`. This exists so a data-only desktop or app pack can change the font FAMILY and not just its size — reaching `pkgs` is precisely what those formats forbid, which made `fonts.mono.package` unreachable to every shared file. A name is data; a package is code. Set one or the other, never both. A name that resolves to nothing, or to a set of packages rather than a package, fails at eval with the spelling to try instead. Example: ```nix "nerd-fonts.fira-code" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.fonts.mono.size` [#hausfontsmonosize] `positive integer, meaning >0` · default `fonts.mono.baseSize, scaled by haus.ui.scale and rounded` Terminal font size in points. The single most useful knob for a larger-text machine, since it moves everything haus actually lives in. nebelhaus's 19pt baseline exists for a reason worth knowing: the Ghostty window is tiled to a fixed pixel height by prowl, and sizes that don't divide that height evenly used to leave a gap under zellij's status bar. That's since been fixed properly (window-padding-balance + `extend-always`), so any size is safe now — 19 is simply the tuned starting point. Example: ```nix 24 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.accessibility [#hausaccessibility] macOS accessibility keys haus can actually apply. These write to a TCC-protected domain, so they take effect only when the app you run the rebuild from holds Full Disk Access — otherwise haus warns and moves on. #### `haus.accessibility.closeViewScrollWheelToggle` [#hausaccessibilitycloseviewscrollwheeltoggle] `null or boolean` · default `null` Hold ⌃ (Control) and scroll to magnify the whole display. macOS calls it "Use scroll gesture with modifier keys to zoom"; scroll the other way to come back. The fastest zoom on the Mac and the one people forget exists. Worth having on a machine you demo, present or pair from, where the alternative is asking everyone to lean in. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. HOW THIS ONE WAS VERIFIED — by a person looking at the screen on real hardware (macOS 26.6.1, 2026-08-14), because no API reports it: `NSWorkspace` exposes no pointer size and no zoom state, so `hausax` cannot read it back and `haus diff` will tell you it compared the plist and nothing more. haus's other accessibility options are checked against macOS's own answer on YOUR Mac; this one carries evidence from one Mac. That is a real difference and it is worth knowing which kind you are getting — it is written here rather than in a changelog because the option is where you will be standing when it matters. NEEDS A DAEMON RESTART, which the rebuild does for you: the write alone changes nothing on screen until `universalaccessd` restarts, which is exactly why this key spent three weeks looking dead. `haus rebuild` kills it whenever this option family is set, so you should never meet the stale state; set the key by hand with `defaults write` and you will, with `killall universalaccessd` as the fix. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.accessibility.closeViewZoomFollowsFocus` [#hausaccessibilitycloseviewzoomfollowsfocus] `null or boolean` · default `null` Keep a zoomed display on whatever has keyboard focus. ⇥ into a field outside the magnified area and the view goes to it — KEYBOARD focus specifically, not the pointer. Needs a zoom to follow, so it does nothing on its own: pair it with `closeViewScrollWheelToggle` (or turn on Zoom in System Settings ▸ Accessibility). nix-darwin's own option says the same, and it is the first thing to check if this appears to do nothing. Expect it to SNAP rather than glide — the view jumps to the focused control in one step. That is the feature behaving, not a rendering fault, and it is the first thing anyone reports as one. Note also that pushing the POINTER at a screen edge pans the zoomed view whether or not this is set: that behaviour is not this option, which matters if you are trying to tell whether it took. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. HOW THIS ONE WAS VERIFIED — by a person looking at the screen on real hardware (macOS 26.6.1, 2026-08-14), because no API reports it: `NSWorkspace` exposes no pointer size and no zoom state, so `hausax` cannot read it back and `haus diff` will tell you it compared the plist and nothing more. haus's other accessibility options are checked against macOS's own answer on YOUR Mac; this one carries evidence from one Mac. That is a real difference and it is worth knowing which kind you are getting — it is written here rather than in a changelog because the option is where you will be standing when it matters. NEEDS A DAEMON RESTART, which the rebuild does for you: the write alone changes nothing on screen until `universalaccessd` restarts, which is exactly why this key spent three weeks looking dead. `haus rebuild` kills it whenever this option family is set, so you should never meet the stale state; set the key by hand with `defaults write` and you will, with `killall universalaccessd` as the fix. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.accessibility.differentiateWithoutColor` [#hausaccessibilitydifferentiatewithoutcolor] `null or boolean` · default `null` macOS's "Differentiate without colour" — native UI adds shapes and text where it would otherwise rely on hue alone. The setting to pair with a desktop built for colour-blind readability. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.accessibility.increaseContrast` [#hausaccessibilityincreasecontrast] `null or boolean` · default `null` macOS's "Increase contrast" — stronger borders and reduced use of colour alone to convey state, across native apps. This is the system-level companion to a high-contrast nebelhaus theme: the theme restyles the tools nebelhaus colours, this reaches everything else. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.accessibility.mouseDriverCursorSize` [#hausaccessibilitymousedrivercursorsize] `null or integer or floating point number between 1.0 and 4.0 (both inclusive)` · default `null` How big the mouse pointer is — 1.0 normal, 4.0 the biggest. That range is macOS's own, and the scale is linear: 2.0 is twice the normal pointer, 4.0 is the largest System Settings offers. The one accessibility key here that is useful with no accessibility need at all: on a 5K display, or from across a desk, or in a screen recording someone else has to follow, the default pointer is simply too small to find. Set it to 1.5 and you keep noticing you can see it. Deliberately NOT wired to `haus.ui.scale`, which would be the obvious thing and is the wrong thing. `ui.scale` is the foundation every machine gets; this domain needs Full Disk Access. Deriving one from the other would make `ui.scale = 1.4` start warning about a TCC grant on machines that never asked for a bigger pointer, for a write that would then be skipped. Reach for this key when you want it — it sharpens a large-text desktop, it does not underpin one. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. HOW THIS ONE WAS VERIFIED — by a person looking at the screen on real hardware (macOS 26.6.1, 2026-08-14), because no API reports it: `NSWorkspace` exposes no pointer size and no zoom state, so `hausax` cannot read it back and `haus diff` will tell you it compared the plist and nothing more. haus's other accessibility options are checked against macOS's own answer on YOUR Mac; this one carries evidence from one Mac. That is a real difference and it is worth knowing which kind you are getting — it is written here rather than in a changelog because the option is where you will be standing when it matters. NEEDS A DAEMON RESTART, which the rebuild does for you: the write alone changes nothing on screen until `universalaccessd` restarts, which is exactly why this key spent three weeks looking dead. `haus rebuild` kills it whenever this option family is set, so you should never meet the stale state; set the key by hand with `defaults write` and you will, with `killall universalaccessd` as the fix. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix 4.0 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.accessibility.reduceMotion` [#hausaccessibilityreducemotion] `null or boolean` · default `null` macOS's "Reduce motion" — Spaces cross-fade instead of sliding, Mission Control and the Dock drop their zoom animations, and window minimise/restore is stripped back. BIGGER THAN IT LOOKS, which is why it is here rather than inside `haus.animations`. This is the single flag every browser maps to the `prefers-reduced-motion: reduce` CSS media query, through `NSWorkspace.accessibilityDisplayShouldReduceMotion` — `hausax` reads that exact property, so `hausax | jq .reduceMotion` is how you check it landed. Turning it on rewrites the web: mostly for the better, except on sites whose scroll-reveal animation is what makes the content visible in the first place, which then never appears at all. If what you want is a snappier Dock and nothing else, `haus.animations = "fast"` is five plain timing keys in two ordinary domains, moves no accessibility flag, and needs no TCC grant. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.accessibility.reduceTransparency` [#hausaccessibilityreducetransparency] `null or boolean` · default `null` macOS's "Reduce transparency" — the menu bar, Control Center, sheets and sidebars stop sampling what is behind them and go opaque. The setting to reach for if Liquid Glass costs you more legibility than it buys, and it pairs naturally with `increaseContrast`. Worth knowing if you run the bar: sill paints its own background, so its pills look the same either way — what changes is the macOS menu bar band behind them, which stops being translucent. null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you. REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is the one property in the whole option surface that can differ between two machines running byte-identical config, so it is worth reading once. The write lands only when the app that runs the rebuild holds Full Disk Access (System Settings ▸ Privacy & Security ▸ Full Disk Access; on macOS 26 a stale grant often needs removing and re-adding with (+), then restarting the terminal). The grant follows the APP, not you and not root — so an agent-driven rebuild in a pane of a terminal that has it works fine, and the same agent under a different app does not. Without the grant haus warns and carries on: you lose this setting and nothing else. That containment is the whole reason `com.apple.universalaccess` has options here at all — written the other way, through `system.defaults.universalaccess.*`, a missing grant aborts the rest of activation and takes every background service haus installs with it. `haus plan` says up front when a rebuild needs the grant, and `haus doctor`'s Permissions section says whether this app has it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.animations [#hausanimations] How much motion macOS spends on its own Dock and windows: the slide, the launch bounce, minimise, Mission Control, window open/close. Unset by default like the rest of this block — `"fast"` opts in, and going back only stops writing rather than restoring. Deliberately not the Accessibility "Reduce motion" switch, which every browser also reads as `prefers-reduced-motion`. #### `haus.animations` [#hausanimations-1] `one of "fast", "system"` · default `"system"` How much motion macOS spends on its own Dock and windows — how long three animations run, and two it plays at all. `"system"` (the default) writes NOTHING — not the macOS values, nothing at all — so whatever your Dock does today, it keeps doing. Same policy as `haus.hotCorners`: haus doesn't overwrite a setting you didn't ask it about. `"fast"` writes five keys, all `mkDefault`, so any one of them can be overridden by name in your host file: ``` com.apple.dock autohide-time-modifier 0.15 Dock slide com.apple.dock expose-animation-duration 0.1 Mission Control com.apple.dock launchanim false the bouncing icon com.apple.dock mineffect scale minimise (not genie) NSGlobalDomain NSAutomaticWindowAnimationsEnabled false window open/close ``` GOING BACK IS NOT AUTOMATIC, which is the one thing about this group that can surprise you and the reason it isn't on by default. Setting `"system"` again means STOP WRITING, not RESTORE: a `defaults` write is sticky and macOS keeps no memory of what was there before, so once you've rebuilt on `"fast"`, the five keys keep haus's numbers. Undoing it means naming the values you want back in your host file (they're `mkDefault`, so a plain value wins), or a `defaults delete`. Worth knowing before you try `"fast"` on a Dock you tuned by hand. WHY THIS ISN'T "REDUCE MOTION". macOS's accessibility switch of that name (`com.apple.universalaccess reduceMotion`) would cover all of this and more — but it is also the single flag every browser maps to the `prefers-reduced-motion: reduce` CSS media query, via `NSWorkspace.accessibilityDisplayShouldReduceMotion`. Turning it on rewrites the web: mostly for the better, except on sites whose scroll-reveal animation is what sets the content visible in the first place, which then never appears at all. These five keys are in two entirely different domains and move no accessibility flag — `hausax` reads that exact `NSWorkspace` property, so `hausax | jq .reduceMotion` stays `false` with this set to `"fast"` (on a machine that hasn't also set `haus.accessibility.reduceMotion`, which is the option that DOES move it) — that's the whole reason this group exists as five curated keys instead of one switch. If you DO want the accessibility switch, it is `haus.accessibility.reduceMotion`: a separate option, in a TCC-protected domain, with that blast radius spelled out on it. Setting both is coherent and neither implies the other. WHEN YOU'LL FEEL IT. The four Dock keys are live the moment activation finishes — nix-darwin restarts the Dock whenever anything in its domain is written, and haus always writes `autohide`. The NSGlobalDomain one is read by each app AT LAUNCH, so apps you already have open keep animating their windows until you relaunch them; `activateSettings` can't reach back into a running `NSApplication`. These are timings, not a state haus can prove from a plist — unlike the `haus.accessibility` keys, there's no oracle for "did the Dock slide faster". They're felt, not measured. The one measurable claim here is the negative one above. Example: ```nix "fast" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.screenshots [#hausscreenshots] Where ⇧⌘4 puts its files, in what format, and whether it draws a window shadow or a preview thumbnail. Unset by default, so macOS's own choices stand. #### `haus.screenshots.format` [#hausscreenshotsformat] `null or one of "png", "jpg", "pdf", "tiff", "heic", "gif"` · default `null` The image format new screenshots are saved in. null (the default) leaves macOS's own choice alone, which is png. png is lossless and the right default for UI and text — a jpg screenshot of a terminal has visible ringing around every glyph. jpg is worth choosing only when you screenshot photographs often enough for the file sizes to matter. Example: ```nix "png" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.screenshots.includeDate` [#hausscreenshotsincludedate] `null or boolean` · default `null` Whether filenames carry the date and time ("Screenshot 2026-08-03 at 13.37.20.png") or just a counter ("Screenshot 1.png"). null (the default) leaves macOS's own choice alone, which is to include it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.screenshots.location` [#hausscreenshotslocation] `null or string` · default `null` Where ⇧⌘3 / ⇧⌘4 / ⇧⌘5 write their files. null (the default) leaves macOS's own choice alone, which is the Desktop. Absolute, or starting with `~/` — haus expands the `~` for you and CREATES the directory during activation. Both halves matter: macOS stores this string verbatim and expands nothing, and if the path does not exist screencapture silently falls back to the Desktop, so a typo'd or not-yet-created folder looks exactly like the setting having been ignored. Example: ```nix "~/Pictures/Screenshots" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.screenshots.shadow` [#hausscreenshotsshadow] `null or boolean` · default `null` Whether a window capture (⇧⌘4 then Space) keeps macOS's big soft drop shadow. null (the default) leaves macOS's own choice alone, which is to include it. false is the setting to want if screenshots go into documentation: the shadow is transparent padding, so it adds a wide invisible margin that every layout then has to fight. Holding ⌥ while you click suppresses it for one capture either way. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.screenshots.thumbnail` [#hausscreenshotsthumbnail] `null or boolean` · default `null` Whether the floating preview thumbnail appears in the bottom-right corner after a capture. null (the default) leaves macOS's own choice alone, which is to show it. false writes the file immediately instead of after the \~5s the thumbnail waits around — the setting to want if you screenshot in quick succession, or if you script anything that reads the file. The cost is losing the markup/drag affordance the thumbnail offers. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.sound [#haussound] Alert volume and sound, interface sound effects, and the boot chime. Volume is 0–100 the way the slider reads it — macOS stores a curve, and haus does the conversion. #### `haus.sound.alertSound` [#haussoundalertsound] `null or one of "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink"` · default `null` Which sound the alert beep plays, by name: ``` Basso Blow Bottle Frog Funk Glass Hero Morse Ping Pop Purr Sosumi Submarine Tink ``` null (the default) leaves macOS's own choice alone. An enum rather than a path on purpose. macOS stores an absolute path here and validates nothing, and a path that doesn't resolve does not fall back to the default beep — it goes SILENT (measured by ear, 2026-08-08), while the plist still reads like a working setting. haus builds the path from the name and skips the write with a warning if that file is missing, so a macOS release retiring a sound can't quietly mute you. Example: ```nix "Submarine" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.sound.alertVolume` [#haussoundalertvolume] `null or integer between 0 and 100 (both inclusive)` · default `null` How loud the alert beep is, 0–100, exactly as the slider in System Settings ▸ Sound reads. null (the default) leaves macOS's own choice alone. haus converts to the exponential value macOS actually stores (`e^(v/100 − 1)`, with 0 meaning silence), because that key is not a fraction: writing the obvious `0.5` gets you 31%. TWO WRITERS: the volume keys and the Sound pane write this same key. Declaring it means every rebuild reasserts your number over anything you changed by hand since — which is the point of declaring it, but leave it null if you'd rather the slider win. Example: ```nix 50 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.sound.startupChime` [#haussoundstartupchime] `null or boolean` · default `null` The chime a Mac plays at boot. null (the default) leaves it alone. The odd one in this group: it is firmware state (`nvram StartupMute`), not a preference, so it survives an OS reinstall and a wiped home directory — and it is the only setting here that needs the rebuild to run as root, which activation already does. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.sound.uiSounds` [#haussounduisounds] `null or boolean` · default `null` Play user-interface sound effects — the Trash whoosh, the screenshot shutter, the Mail whoosh. null (the default) leaves macOS's own choice alone. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.sound.volumeFeedback` [#haussoundvolumefeedback] `null or boolean` · default `null` Play a sound when the volume keys change the volume. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ## Displays [#displays] Resolution and per-display behaviour, addressed by which screen you mean rather than by a panel's serial number. ### haus.displays [#hausdisplays] Per-display overrides, keyed by which screen you mean. #### `haus.displays` [#hausdisplays-1] `attribute set of (submodule)` · default `{ }` Per-display settings, keyed by which screen you mean: internal the built-in panel main whichever display is currently main \ a persistent display UUID, for a specific external monitor — run `hausdisp list` to print the UUIDs of what's attached Default is the empty set, and then nothing about your displays is touched. A key naming a display that isn't plugged in right now is skipped with a note, not an error, so a `displays.` entry for the monitor at the office can't fail a rebuild on the train. Why this option exists at all: display scaling is the only lever macOS 26 gives us for "make EVERYTHING bigger", system-wide, including apps haus knows nothing about. macOS's own text-size setting writes a value no running app re-reads, while the accessibility scalars that do work affect contrast or motion rather than system-wide size — measured, not assumed (the workshop's notes/macos-settings-matrix.md records the sweep). So `haus.ui.scale` and `haus.fonts` make *haus's own tools* bigger, and this makes the *Mac* bigger. Example: ```nix { "37D8832A-2D66-02CA-B9F7-8F30A301B230" = { uiScale = "more-space"; }; internal = { uiScale = "larger-text"; }; } ``` Declared in [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix) . #### `haus.displays..uiScale` [#hausdisplaysnameuiscale] `null or one of "more-space", "default", "larger-text", "largest-text"` · default `null` The scaled resolution, as an intent rather than a pixel count — the same four positions System Settings ▸ Displays offers, named: more-space the largest resolution the panel offers (smallest UI) default the panel's own default mode larger-text between the default and the smallest resolution largest-text the smallest resolution the panel offers (biggest UI) Resolved per panel from the modes that panel actually reports, so the same value means the same *thing* on a 14" laptop and a 27" monitor rather than the same number of pixels. On the 14" MacBook Pro this was developed on that resolves to 1800x1169 · 1512x982 · 1147x745 · 1024x665. Applied at each home-manager activation and set permanently, so it survives a reboot; re-applying an already-current mode is a no-op, so a rebuild doesn't flash your screen. null (the default) leaves the display alone. When more than one selector names the same attached panel, the more specific setting wins: UUID over internal over main. This lets a host-specific display setting refine a broad profile such as `haus.appearance.largePrint` without depending on activation order. Honest scope: this is a real, system-wide size change — every app gets bigger, not just haus's own tools — and the cost is desk space, because a larger UI means less of it. It also can't run from a rebuild with no GUI session attached (over SSH, say); the setting applies at the next activation you run while logged in. Example: ```nix "larger-text" ``` Declared in [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix) . ## Development [#development] The terminal stack — terminal, shell, multiplexer, editor — plus the browser, the CLI toolbelt, Git tooling and language runtimes. Your commit identity itself is a fact about you rather than this room's, and stays in your host. The terminal lives here because a terminal with no tools in it is not a separate thing anyone wants. ### haus.hearth [#haushearth] The shell and terminal experience. #### `haus.hearth.editor` [#hausheartheditor] `string` · default `the command for haus.hearth.editorName — hx for helix` The ONE editor command haus uses everywhere. It's the shell command for $EDITOR / $VISUAL (git, etc.) AND what every "open in an editor" action launches — the "Nix Config" palette command, the bar's nix-open item, and the file-association hijack. Those open the target in a new zellij tab running this command, so a terminal editor is the natural fit for haus; a GUI editor's CLI works too (e.g. "code" or "code -w" to block). It defaults to the command for `haus.hearth.editorName`, so choosing an editor there is enough. Set this only for the case that option cannot express: pointing haus at something it does not install. Naming a command here does NOT install it — that machine has to already have it. Example: ```nix "code -w" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.editorName` [#hausheartheditorname] `one of "helix", "nano", "neovim", "vim"` · default `"helix"` Which editor this room installs. `helix` (the default) is the one haus is themed around; `neovim`, `vim` and `nano` are installed as-is, with no Nebelung theme — Nebelung has a port for helix and not for them. Setting this also moves `haus.hearth.editor`, since that defaults to whatever the chosen editor answers to on PATH (`hx`, `nvim`, `vim`, `nano`). Choosing here is the whole gesture: the editor is installed AND every "open in an editor" action follows it. A desktop may set this. To point haus at an editor it does not install — a GUI one, or something from your own host file — leave this alone and set `haus.hearth.editor` instead. Example: ```nix "neovim" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.floatBorder` [#haushearthfloatborder] `one of "accent", "grey", "off", "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `"accent"` The outline drawn around every floating terminal `float-term.sh` spawns: the Super-y yazi peek panel, the bar's agent peek, and the palette's Rebuild System / Install App / Settings and `zscratch` windows. They all land on top of a tiled desktop, where a dark terminal over a dark window behind it has no edge at all. * `accent` (the default) — `haus.theme.accent`, so a summoned window announces itself and the whole desktop keeps one accent. * `grey` — Nebelung's `surface0`, one step off the terminal's own background: the same relationship the bar's dropdowns wear (`popup.background.border_color` in modules/sill), for an edge that defines the window without drawing the eye. * `off` — no outline; the look before this option existed. It also keeps floatring out of the closure entirely, so nothing is compiled for it. * any Nebelung accent name (`lavender`, `sapphire`, …) — one colour for these popups that ISN'T `haus.theme.accent`, the same escape hatch `haus.sill.logo.color` offers. 2pt, following the window's own corner curve. Drawn by a tiny overlay window (modules/hearth/floatring.swift) that lives and dies with the popup, because Ghostty has no border setting of its own and aerospace draws none — that file's header has the rest, including why it isn't JankyBorders. Switch it with `haus set hearth.floatBorder grey && haus rebuild`; to compare colours first, without a rebuild, outline any window by hand (the process name is lower-case — `pgrep -x Ghostty` matches nothing and rings nothing): `~/.config/zellij/float-term.sh ring "$(pgrep -x ghostty | head -1)" '#cba6f7'` Example: ```nix "grey" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.ghDash.enable` [#haushearthghdashenable] `boolean` · default `false` Whether to enable the themed gh-dash GitHub dashboard and its Cmd-G fullscreen Zellij overlay. Enabling it gets you the issue and notification tabs (yours, assigned, unread, participating). The four PR tabs — open / green / red / shipped — need `haus.git.org` as well, since a PR section is a search filter scoped to an owner. A host can compose or replace any of it through home-manager's `programs.gh-dash.settings`: every section list Hearth writes is a `mkDefault`, per list. Needs `haus.developer.git.enable` (an assertion enforces it): gh-dash authenticates out of `gh`'s own credentials, so the Git pack is where its login comes from. Example: ```nix true ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.hijackFileAssociations` [#haushearthhijackfileassociations] `boolean` · default `false` When true, build a small opener app and make it the default handler for \~80 text/code extensions (json, md, ts, nix, rs, go, kdl, …), so opening or clicking those files opens them in haus.hearth.editor in a terminal tab. The app declares the types itself (not just `duti`) so extensions nothing else on the machine declares still bind. Off by default: silently rewriting your file associations is a jarring, hard-to-undo change, so it's strictly opt-in. (Extensionless executables like `bench` are NOT covered — macOS gates the public.unix-executable handler behind an interactive dialog; set it by hand once if wanted: `duti -s org.nebelhaus.editoropen public.unix-executable all`.) Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.lanes.backend` [#haushearthlanesbackend] `one of "zellij", "zmx"` · default `"zellij"` Where an agent lane's terminal actually lives. `zellij` (the default) is the behaviour haus has always had: a lane is a pane in the `main` zellij session, and `holt` execs the client in the pane you ran it from. Panes are cheap, but a lane's identity is then a (session, pane-id) pair that only zellij understands — which is why the bar keeps a state file per pane and joins it back to a checkout path to work out which window to raise. `zmx` makes the lane its own zmx session, viewed through its own Ghostty window, tiled by prowl — all three named `holt..`. Three consequences, in the order you'd feel them: * **Closing the window stops meaning parking the work.** A zmx session outlives every client attached to it, so ⌘W detaches and the agent keeps thinking; `holt ` reopens a window onto the live conversation instead of resuming a transcript. * **The name is the join.** `zmx ls --where state=waiting`, AeroSpace's `window-title-regex`, and the lane in `holt --json` all key off one string. * **Splits are gone**, because zmx has none by design. Prowl tiles the windows instead, which is the trade: a real window manager rather than a second one nested inside a terminal. Both backends can be installed at once — this only picks which one `holt` opens into, through the `[hooks] open`/`resume` seam in `~/.config/holt/config.toml`. If zmx is somehow missing at runtime the hook defers (exit 3) and holt falls back to its built-in, so the worst case is the zellij behaviour you already had. Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.obsidianVaults` [#haushearthobsidianvaults] `list of string` · default `[ ]` Home-relative paths to existing Obsidian vaults that should use the Nebelung theme. On each activation, Hearth copies the rendered theme.css + manifest.json into each vault's .obsidian/themes/Nebelung/ directory, selects Nebelung's dark appearance in appearance.json, and removes the obsolete "nebelung" CSS snippet from the enabled list. Empty (the default) leaves every vault untouched. Paths must be relative to the user's home, may not contain "..", and are skipped with a warning unless their .obsidian directory already exists. Example: ```nix [ "Library/Mobile Documents/iCloud~md~obsidian/Documents/notes" ] ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.rightClickFullscreen` [#haushearthrightclickfullscreen] `boolean` · default `true` When true (the default), a bare right-click on any pane zooms it fullscreen — the same MouseAction::ToggleFullscreen Ctrl+Click already triggers, just a different, easier-to-reach trigger. It's a whole zellij-unwrapped patch, not a config toggle (mouse buttons still aren't bindable in config.kdl — see naked-click-links.patch's header for why link gestures hit the same wall), so flipping this rebuilds zellij; it does not take effect on a running server. The real cost: right-click stops reaching the pane's own program, so a TUI's own right-click context menu (lazygit, vim, mc, …) goes with it. Set false to leave right-click alone and keep zooming with Ctrl+Click or Super Enter. Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.hearth.zellijStartLocked` [#haushearthzellijstartlocked] `boolean` · default `true` When true (the default), zellij boots into Locked input mode instead of Normal — its single-key submode leaders (pane, tab, resize, …) stay inert until you unlock with Ctrl-g, so a stray keystroke can't jump you into a submode. The `Super`-prefixed launchers (claude / pane / tab / yazi-peek / fullscreen) are bound in `shared` and keep working while locked, as do `Alt [` / `Alt ]` (cycle swap layouts) — the rest of zellij's `Alt` row stays inert while locked, since those keys are readline/vim word motions the pane's app wants. The bar's bottom-right quick-hint block only shows in Locked mode. Set false to start in Normal mode (zellij's own default). Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . ### haus.zen [#hauszen] Zen browser policy, extensions and the optional native tab bridge. #### `haus.zen.extensions` [#hauszenextensions] `attribute set of (submodule)` · default `{ }` Browser extensions to deploy into Zen, by a stable id of your choosing. The mechanism is Firefox's enterprise policies — haus renders an `ExtensionSettings` block — so it reaches Zen the way an IT department reaches Firefox, without a profile to hand-edit. `haus.roster` deliberately cannot do this: a roster entry installs from a cask, a brew, a nixpkgs package or the App Store, and a browser add-on is none of those. Two consequences of HOW the policies are delivered, both visible. Firefox only ever looks for a `policies.json` inside the app bundle, which haus has no business writing into (it breaks the code signature and a cask upgrade wipes it), so haus uses the other route macOS offers: a managed preference at `/Library/Preferences/app.zen-browser.zen.plist`. That file is root-owned, so it's written during system activation and a `haus rebuild` that can't reach it warns instead of installing anything. And because enterprise policies are on, Zen will tell you it is "managed by your organization" — that organization is haus. haus knows the id and slug of the extensions it themes (stylus), so those need only be named. Everything else needs `id` — see that option for where to find it. Naming `stylus` here also turns on the stamped userstyle bundle (see haus.theme.accent): the Catppuccin-derived styles Stylus imports carry their own accent and flavor variables, which no palette file can reach, so haus stamps the bundle from your theme — accent, flavor, and the contrast it's rendered for — and tells you when there's a new one to import. Example: ```nix { # Known to haus — id and slug are filled in. stylus = { }; # Anything else: bring the id. ublock-origin = { id = "uBlock0@raymondhill.net"; slug = "ublock-origin"; }; } ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.extensions..enable` [#hauszenextensionsnameenable] `boolean` · default `true` Whether to deploy this extension. Set false to remove one an imported desktop added. Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.extensions..id` [#hauszenextensionsnameid] `null or string` · default `null` The extension's own id — the key Firefox's policy engine matches on, NOT its AMO slug. Usually a brace-wrapped UUID, sometimes an email-shaped string (`addon@example.org`). Find it by installing the add-on once and reading `Extension ID` under about:debugging ▸ This Firefox, or from the `browser_specific_settings` block of its source. Wrong id and the policy silently installs nothing — which is why this has no guessable default. Example: ```nix "{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.extensions..mode` [#hauszenextensionsnamemode] `one of "force_installed", "normal_installed", "allowed", "blocked"` · default `"force_installed"` Firefox's `installation_mode`. `force_installed` installs it and stops the user removing it (the point, for a desktop that wants an extension present); `normal_installed` installs it but leaves it removable. Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.extensions..slug` [#hauszenextensionsnameslug] `null or string` · default `null` The add-on's AMO slug — the last path segment of its addons.mozilla.org URL. Only used to build the default `url`; set `url` directly and this is ignored. Example: ```nix "styl-us" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.extensions..url` [#hauszenextensionsnameurl] `string` · default `""` Where the .xpi comes from. Defaults to AMO's "latest" endpoint for `slug`, so the add-on updates itself; point it at a pinned version or a self-hosted file to freeze it. A `file://` url has a second effect, and it is not local to this extension: a file on disk cannot have been signed by Mozilla, and Zen refuses an unsigned add-on (`ERROR_SIGNEDSTATE_REQUIRED`) unless `xpinstall.signatures.required` is off. So naming one makes haus lock that pref off **for the whole browser** — the same switch `haus.zen.tabBridge.enable` documents, since the bridge is haus's own `file://` install. An `https://` AMO url never turns it on. Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.extraPolicies` [#hauszenextrapolicies] `attribute set` · default `{ }` Anything else to put in Zen's policy set, merged beside the `ExtensionSettings` block `haus.zen.extensions` renders. haus OWNS the file these land in — `/Library/Preferences/app.zen-browser.zen.plist`, written as root — so this is the escape hatch for the rest of the policy surface rather than a reason to take the file back by hand. Keys here win over haus's on a collision. Write the policy names as Firefox documents them, nested: this becomes the top level of a plist beside `EnterprisePoliciesEnabled`, so `{ Extensions.Install = [ "…" ]; }` is an `Extensions` dict with an `Install` array in it, not a key called `Extensions.Install`. Setting every policy back to `{ }` (and naming no extensions) takes the file down again on the next rebuild. The merge is one level deep, so naming a policy takes that policy over WHOLE. Two of them haus writes itself: `ExtensionSettings` (from `haus.zen.extensions`) and `Preferences` (which is where the signature switch a `file://` install needs ends up). Restate what you still want if you set either — dropping the signature switch this way is invisible until you notice the add-on isn't there. Values are passed to a plist writer, so `null` is not a value: it renders as a key with nothing under it, which makes the whole file invalid and drops **every** policy, not just that one. Omit the key instead. Example: ```nix { DisableTelemetry = true; } ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.zen.tabBridge.enable` [#hauszentabbridgeenable] `boolean` · default `false` Deploy haus's own tiny extension into Zen, so the bar can find and switch to the tab that is making noise. This is what makes the media pill's ⌘ click land on the **tab** rather than just bringing Zen forward. Safari and the Chromium browsers need nothing here — they hand their tab list to AppleScript and the pill uses that. Firefox and its forks hand out nothing at all, to AppleScript or to accessibility, so without this the pill falls back to driving Firefox's own address-bar tab search with synthetic keystrokes, which needs the Accessibility permission and is exactly as pleasant as it sounds. Off by default because it force-installs an add-on into your browser, which is not a thing haus should do to you unasked. Turning it on costs one derivation, a native-messaging manifest, and two keys in haus's root-owned policy plist — one of which is the signature switch below. Turning it back off stops haus deploying it — what Zen then does with the add-on already installed is Firefox's policy engine's business, not haus's, so check `about:addons` and remove it there if it outstays the option. **Zen only, and that's a signing constraint rather than a choice.** Release Firefox refuses an extension Mozilla hasn't signed, and it is built so that no pref and no policy can say otherwise. Zen is built the other way (`MOZ_REQUIRE_SIGNING = false`), which is the whole reason haus can build the `.xpi` itself and install it out of the nix store. It still costs a switch. Zen carries Firefox's own preference defaults, which turn signature enforcement back on, so turning this option on also makes haus lock `xpinstall.signatures.required = false` — for the browser, not just for its own add-on. Without it Zen refuses the bridge with `ERROR_SIGNEDSTATE_REQUIRED` and the option quietly does nothing; with it, an unsigned add-on from anywhere would also install if something asked. That is the second reason this is off by default. Firefox support would mean an AMO account and unlisted self-distribution signing — packaging, not a code change — and would drop the pref. Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . ### haus.developer [#hausdeveloper] The developer pack: the CLI toolbelt, Git tooling and language runtimes. Coding agents left this pack on 2026-08-13 and are their own room now (`haus.ai.*`). Off is a nebelhaus machine for someone who never opens a terminal by choice. #### `haus.developer.enable` [#hausdeveloperenable] `boolean` · default `false` The Development room: the CLI toolbelt, Git tooling and language runtimes. The neutral catalogue leaves it off; nebelhaus selects it in its desktop. Coding agents left this pack on 2026-08-13 and are their own room now (`haus.ai.*`). The two rooms are independent: a desktop or host selects each one explicitly. `false` is what makes a non-developer nebelhaus possible — it strips those tools rather than merely hiding them. What remains is the product: `haus`, `awake`, the theme, the terminal, the bar, the tiler and the palette. The Git and toolbelt sub-options below default to this value, so a host can turn the room on and then remove one piece: haus.developer.enable = true; haus.developer.git.enable = false; # …but leave Git tooling out Example: ```nix true ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.developer.git.enable` [#hausdevelopergitenable] `boolean` · default `config.haus.developer.enable` Git and its surroundings: the shell alias vocabulary, the themed git config, delta (diff pager), lazygit, `gh`, and gnupg for commit signing. Off drops all of them, and `haus.git.*` then has nothing to configure. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.developer.languages` [#hausdeveloperlanguages] `list of value "node" (singular enum)` · default `[ ]` Language runtimes to install. Currently only "node" (bun + fnm, with fnm's `--use-on-cd` shell hook). Deliberately a list rather than one bool per language, so adding "rust" or "python" later doesn't change this option's shape. Example: ```nix [ ] ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.developer.toolbelt.enable` [#hausdevelopertoolbeltenable] `boolean` · default `config.haus.developer.enable` The terminal toolbelt: bat, fzf, fd, ripgrep, yazi, zoxide, lsd, glow, jq, tree, chafa, ttyd and fastfetch — the themed replacements for cat, find, grep, ls and friends that haus's shell is built around. Off leaves a plain shell. The prompt (starship) and the colour scheme stay: these are the *tools*, not the appearance. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . ## Windows [#windows] Tiling, window navigation, hot corners, and the leader key that launches an app or throws it somewhere. The workspaces themselves (`haus.workspaces`) and the keys haus claims (`haus.keys`) are shared surfaces below, because the bar and the launcher read them too. ### haus.hotCorners [#haushotcorners] What each corner of the screen does when the pointer reaches it. Every corner is unset by default, so haus never overwrites one you set yourself. #### `haus.hotCorners.bottomLeft` [#haushotcornersbottomleft] `null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null` What happens when the pointer reaches the bottom-left corner of the main display. ``` disabled nothing happens — the corner is explicitly claimed and left inert mission-control Mission Control: every window and Space, zoomed out application-windows App Exposé: every window of the app you're in desktop push all windows aside and show the desktop launchpad the grid of installed apps (on macOS 26 this opens the Apps view) notification-center slide out Notification Center and its widgets quick-note start a Quick Note — Apple's own default for the bottom-right corner screen-saver start the screen saver immediately prevent-screen-saver hold the screen saver off while the pointer rests here sleep-display put the display to sleep (the machine keeps running) lock-screen lock the screen and return to the login window ``` null (the default) writes nothing at all, which is not the same as "disabled": corners are a setting people have usually already made by hand, and a desktop that names one it doesn't care about would silently erase it. Use `"disabled"` to explicitly claim a corner and make it inert. Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for this corner" separately (`wvous-*-modifier`), and a leftover modifier from an earlier setup makes a corner you just declared look broken — nothing happens, because you weren't holding the key nobody told you about. Corners left at null keep whatever modifier they have. Worth knowing if you also run tiling: `mission-control` and `desktop` are macOS's own window and Space management, which prowl replaces. They still work, they just show you a view of the windows prowl is arranging. Example: ```nix "mission-control" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.hotCorners.bottomRight` [#haushotcornersbottomright] `null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null` What happens when the pointer reaches the bottom-right corner of the main display. ``` disabled nothing happens — the corner is explicitly claimed and left inert mission-control Mission Control: every window and Space, zoomed out application-windows App Exposé: every window of the app you're in desktop push all windows aside and show the desktop launchpad the grid of installed apps (on macOS 26 this opens the Apps view) notification-center slide out Notification Center and its widgets quick-note start a Quick Note — Apple's own default for the bottom-right corner screen-saver start the screen saver immediately prevent-screen-saver hold the screen saver off while the pointer rests here sleep-display put the display to sleep (the machine keeps running) lock-screen lock the screen and return to the login window ``` null (the default) writes nothing at all, which is not the same as "disabled": corners are a setting people have usually already made by hand, and a desktop that names one it doesn't care about would silently erase it. Use `"disabled"` to explicitly claim a corner and make it inert. Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for this corner" separately (`wvous-*-modifier`), and a leftover modifier from an earlier setup makes a corner you just declared look broken — nothing happens, because you weren't holding the key nobody told you about. Corners left at null keep whatever modifier they have. Worth knowing if you also run tiling: `mission-control` and `desktop` are macOS's own window and Space management, which prowl replaces. They still work, they just show you a view of the windows prowl is arranging. Example: ```nix "mission-control" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.hotCorners.topLeft` [#haushotcornerstopleft] `null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null` What happens when the pointer reaches the top-left corner of the main display. ``` disabled nothing happens — the corner is explicitly claimed and left inert mission-control Mission Control: every window and Space, zoomed out application-windows App Exposé: every window of the app you're in desktop push all windows aside and show the desktop launchpad the grid of installed apps (on macOS 26 this opens the Apps view) notification-center slide out Notification Center and its widgets quick-note start a Quick Note — Apple's own default for the bottom-right corner screen-saver start the screen saver immediately prevent-screen-saver hold the screen saver off while the pointer rests here sleep-display put the display to sleep (the machine keeps running) lock-screen lock the screen and return to the login window ``` null (the default) writes nothing at all, which is not the same as "disabled": corners are a setting people have usually already made by hand, and a desktop that names one it doesn't care about would silently erase it. Use `"disabled"` to explicitly claim a corner and make it inert. Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for this corner" separately (`wvous-*-modifier`), and a leftover modifier from an earlier setup makes a corner you just declared look broken — nothing happens, because you weren't holding the key nobody told you about. Corners left at null keep whatever modifier they have. Worth knowing if you also run tiling: `mission-control` and `desktop` are macOS's own window and Space management, which prowl replaces. They still work, they just show you a view of the windows prowl is arranging. Example: ```nix "mission-control" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.hotCorners.topRight` [#haushotcornerstopright] `null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null` What happens when the pointer reaches the top-right corner of the main display. ``` disabled nothing happens — the corner is explicitly claimed and left inert mission-control Mission Control: every window and Space, zoomed out application-windows App Exposé: every window of the app you're in desktop push all windows aside and show the desktop launchpad the grid of installed apps (on macOS 26 this opens the Apps view) notification-center slide out Notification Center and its widgets quick-note start a Quick Note — Apple's own default for the bottom-right corner screen-saver start the screen saver immediately prevent-screen-saver hold the screen saver off while the pointer rests here sleep-display put the display to sleep (the machine keeps running) lock-screen lock the screen and return to the login window ``` null (the default) writes nothing at all, which is not the same as "disabled": corners are a setting people have usually already made by hand, and a desktop that names one it doesn't care about would silently erase it. Use `"disabled"` to explicitly claim a corner and make it inert. Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for this corner" separately (`wvous-*-modifier`), and a leftover modifier from an earlier setup makes a corner you just declared look broken — nothing happens, because you weren't holding the key nobody told you about. Corners left at null keep whatever modifier they have. Worth knowing if you also run tiling: `mission-control` and `desktop` are macOS's own window and Space management, which prowl replaces. They still work, they just show you a view of the windows prowl is arranging. Example: ```nix "mission-control" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.prowl [#hausprowl] Tiling window management and the Caps-Lock leader launcher. #### `haus.prowl.enable` [#hausprowlenable] `boolean` · default `false` AeroSpace tiling window management + the leader-key launcher. This is the room switch: off drops AeroSpace, its launch agent, the wake-time window re-sort and the key remap entirely. To keep the tiler but leave the keyboard alone, use haus.keys.leader = "none" and haus.keys.windowNav = "none" instead of turning the room off. Declared in [`modules/prowl/options.nix`](https://github.com/hausfold/haus/blob/main/modules/prowl/options.nix) . ## Bar [#bar] The menu bar: where it draws, which pills it carries, and what each one reads. ### haus.menuBar [#hausmenubar] The stock menu bar: what the clock shows, and which Control Center glyphs sit beside it. (The nebelhaus bar itself is `sill`.) #### `haus.menuBar.clock.analog` [#hausmenubarclockanalog] `null or boolean` · default `null` Draw an analog clock face instead of a digital readout. null (the default) leaves macOS's own choice alone (digital). Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.clock.format` [#hausmenubarclockformat] `null or one of "12h", "24h"` · default `null` 12-hour or 24-hour menu bar clock. null (the default) leaves macOS's own choice alone (region-dependent, usually 12h in the US). Example: ```nix "24h" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.clock.showDate` [#hausmenubarclockshowdate] `null or one of "when-space-allows", "always", "never"` · default `null` Whether the full date appears next to the time. null (the default) leaves macOS's own choice alone ("when-space-allows"). Example: ```nix "always" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.clock.showDayOfWeek` [#hausmenubarclockshowdayofweek] `null or boolean` · default `null` Show the day of the week next to the clock. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.clock.showSeconds` [#hausmenubarclockshowseconds] `null or boolean` · default `null` Show the clock to second precision instead of minutes. null (the default) leaves macOS's own choice alone. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.airdrop` [#hausmenubarcontrolcenterairdrop] `null or boolean` · default `null` Whether the AirDrop control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.batteryPercentage` [#hausmenubarcontrolcenterbatterypercentage] `null or boolean` · default `null` Show the battery percentage next to its menu bar icon. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.bluetooth` [#hausmenubarcontrolcenterbluetooth] `null or boolean` · default `null` Whether the Bluetooth control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.displayBrightness` [#hausmenubarcontrolcenterdisplaybrightness] `null or boolean` · default `null` Whether the Screen Brightness control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.focus` [#hausmenubarcontrolcenterfocus] `null or boolean` · default `null` Whether the Focus control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.nowPlaying` [#hausmenubarcontrolcenternowplaying] `null or boolean` · default `null` Whether the Now Playing control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.menuBar.controlCenter.sound` [#hausmenubarcontrolcentersound] `null or boolean` · default `null` Whether the Sound control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.sill [#haussill] The menu bar, and which pills it draws. #### `haus.sill.aiUsage.provider` [#haussillaiusageprovider] `one of "latest", "claude", "codex", "opencode"` · default `"latest"` Which AI provider to display in the main pill: `latest` (default, automatically shows whichever provider reported most recently), or one of `claude`, `codex`, `opencode`. Clicking the pill always displays the full dropdown with all reporting providers. Note this is about *usage readouts*, not about which client `holt` can spawn: a provider reports here whenever it has data for your account — Codex notably does so from a ChatGPT login alone, with no CLI installed — so it is deliberately not tied to `haus.ai.clients`. Example: ```nix "claude" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.battery.hideOver` [#haussillbatteryhideover] `null or signed integer` · default `null` Hide the battery pill when charge percentage is above this threshold (e.g., set to 80 to show the battery pill only when charge is at or below 80%). Example: ```nix 80 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.enable` [#haussillbottomenable] `boolean` · default `false` Draw a SECOND bar along the bottom of the screen, at the same time as the menu bar one. `haus.sill.bottom.items` picks what goes on it and which of its three groups — left, center, right — each pill lands in; an empty set draws an empty strip, which the module warns about. SketchyBar has no two-bars-in-one-process mode — an instance is named after `basename(argv[0])` and keys both its lock file and its mach service on that name — so this is a second launchd agent running the SAME binary under a second name, `sill-bottom`. That name is also the CLI for it: `sill-bottom --set cpu label=…` talks to the bottom bar the way `sketchybar --set` talks to the menu bar one. Two things macOS does not do for you here. It reserves the top strip of every display for the menu bar but reserves NOTHING at the bottom, so windows would sit under this bar: prowl carves the room out of its outer-bottom gap whenever this is on (with `haus.prowl.enable = false`, nothing reserves it and your windows will run underneath). And the Dock, if you keep it at the bottom, shares that edge — move it to a side, or leave it hidden. Example: ```nix true ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items` [#haussillbottomitems] `submodule` · default `{ }` Which pills the bottom bar draws, and WHERE along it — one value each, all default false. A pill named here MOVES: it is drawn on the bottom bar and not on the menu bar, whatever `haus.sill.items` says about it — so there is one switch per pill per bar and never two copies of the same readout. Each value is `false` (not on this bar), one of `"left"`, `"center"`, `"right"` — the bar's three groups — or `true`, which is `"right"`: haus.sill.bottom.items = \{ agents = "left"; media = "center"; clock = "right"; cpu = true; # same as "right" }; Within a group the order is fixed (the same order the menu bar uses), and each group packs outward from its own edge: on the `right` the first pill sits furthest right, exactly as `clock` does up top, while `left` fills rightward from the left edge and `center` grows around the middle of the screen. All three are offered here and only `right` is offered on the menu bar, because this strip has nothing else on it: no workspace pills, no front-app slot, and no notch across its middle. The set is the five core pills (`clock`, `weather`, `media`, `battery`, `wifi`) plus the `haus.sill.items` extras (`cpu`, `memory`, `volume`, `calendar`, `caffeinate`, `agents`, `aiUsage`, `elgato`, `harvest`), plus the Hush pill when `haus.hush.enable` is on. The whole left side (workspace pills, front app, the leader picker) and the tour stay on the menu bar. Needs `haus.sill.bottom.enable`; without it nothing here is drawn. Example: ```nix { agents = "left"; clock = "right"; media = "center"; } ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.agents` [#haussillbottomitemsagents] `boolean or one of "left", "center", "right"` · default `false` A paw pill tracking your agent-worktree panes. The label always names the state worth interrupting you for — "2 ready" outranks "5 working", which outranks "1 done" — never a bare count you'd have to click to decode. Click for the per-agent breakdown, sorted the same way (waiting first, then working, then idle, longest-elapsed first within each), each block showing the client, how long it's sat in that state, and — when the pane's checkout is a `holt` lane — its repo and PR status: merged, `+N unshipped` (exactly what `holt reship` fixes), not yet landed, or a dirty-tree footnote. A summary header totals the counts once more than one agent is running. Left-click a row to jump to that pane, ⌥/right-click for a live `zellij subscribe` peek. Fed by each client's own lifecycle hooks, which all call `agent-state` (also installed as \~/.config/sketchybar/plugins/agents-hook.sh): Opencode's plugin and Codex's \~/.codex/hooks.json are written for you (Codex asks you to trust its hooks the first time it sees them), while Claude Code's four agent-state hooks stay yours to point at it in \~/.claude/settings.json — Claude owns that file and rewrites it, so haus merges in only the keys it must and never touches those four. (The two worktree hooks ARE declared, in hearth: they point at a haus-controlled path and self-heal on rebuild.) A row whose zellij pane is gone drops off by itself, which is what stands in for the session-end event Codex doesn't have. Dormant until a client fires. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.aiUsage` [#haussillbottomitemsaiusage] `boolean or one of "left", "center", "right"` · default `false` A gauge pill showing AI usage (Claude Code/Codex subscription rate limits as %, or Opencode API token cost as daily $). Automatically shows whichever provider reported most recently. Click for expanded session/weekly limits and daily/monthly API costs with model breakdowns. Claude and Opencode are read off disk; Codex has no local usage data, so its row is polled from your ChatGPT account with the OAuth token in \~/.codex/auth.json (refreshed and rewritten in place) — no Codex login on the machine, no call is made. Claude's row is pushed by its statusline; the Codex and Opencode rows are pulled by the pill itself on a 3-minute TTL, so they stay current on a machine that never opens Claude at all. Claude and Opencode also get a `tokens` block in the dropdown — raw tokens moved today, this week, this month and all time (cache reads and all), two periods to a line so a full set reads as a 2×2, purely for the fun of watching the number climb. A period with nothing in it is left out rather than printed as a zero, so the block simply gets smaller, and a closing `∑ Everything` adds every provider up when more than one is reporting. It is a score, not a limit: nothing acts on it, and it never reaches the pill's own label. Claude's is summed from your transcripts on a 15-minute TTL behind an index, so only sessions that grew since the last pass are re-read; Codex has no row because it keeps no local history to count. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.battery` [#haussillbottomitemsbattery] `boolean or one of "left", "center", "right"` · default `false` The battery pill. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.caffeinate` [#haussillbottomitemscaffeinate] `boolean or one of "left", "center", "right"` · default `false` A coffee pill that prevents idle system sleep for 1/2/4/8 hours, a custom whole-hour duration, or indefinitely. The display may still turn off; closing a MacBook lid still sleeps it. Uses macOS's built-in `caffeinate`, so there is no extra package. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.calendar` [#haussillbottomitemscalendar] `boolean or one of "left", "center", "right"` · default `false` The one meeting you have to be at next, and one gesture to join it. It reads "in 12m · Design review" — countdown first, because a label is clipped from the END and the number is the part you must never lose; below `haus.sill.calendar.preciseUnder` hours it carries minutes, above it just "in 14h" or "in 2d", and while an event is running it says "now · …" instead of going blank. For `haus.sill.calendar.imminent` minutes either side of the start the whole pill FILLS with the accent — a shape change rather than a colour change, so it catches the eye you aren't pointing at it. RIGHT-CLICK joins: it opens the event's conferencing link, found in the invite's url, location or notes (Meet, Zoom, Teams, Webex, Jitsi, Whereby and friends out of the box; `haus.sill.calendar.joinHosts` adds your own). LEFT-CLICK opens the day as a timeline — what's DONE in the last `haus.sill.calendar.past` hours, what's on NOW, and what's NEXT — each event carrying its day, clock time, length and who it's with, the next one boxed, and a `Join` affordance on every row that has a link. Your own address is dropped from the "with" line automatically: a CalDAV calendar is named for the account it syncs, so the pill can work out which attendee is you with no configuration (`haus.sill.calendar.me` for the cases where it can't). A name too long for the pill sweeps past only while you HOVER it — nothing here starts a marquee on its own — and `haus.sill.calendar.width` sets how much room it gets before that applies. Pulls in `ical-buddy` automatically and reads Calendar, so macOS prompts for Calendar access on first run. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.clock` [#haussillbottomitemsclock] `boolean or one of "left", "center", "right"` · default `false` The clock pill, pinned to the far right. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.cpu` [#haussillbottomitemscpu] `boolean or one of "left", "center", "right"` · default `false` Total CPU load, drawn as a graph pill: the last two minutes of it behind the number, because a percentage on its own can't tell a spike settling from a climb that started five minutes ago. The reading is a DELTA between samples — the `ps` sum this used to print is each process's average over its whole lifetime, which on a machine that has been up a week barely moves while every core is pinned. LEFT-CLICK opens a dropdown: the user/system split, the load average, then what's responsible, biggest first and aggregated per app so a browser's twenty helpers are one row; clicking a row focuses that app's window. RIGHT-CLICK opens Activity Monitor on its CPU tab. The rows can only cover processes you own, so anything root runs — `kernel_task`, `WindowServer` — lands in `everything else` rather than going quietly missing from the sum. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.elgato` [#haussillbottomitemselgato] `boolean or one of "left", "center", "right"` · default `false` Toggles an Elgato Key Light on the local network. The light is found over mDNS (or pinned with `haus.sill.elgato.host`), and the pill draws dim when it can't be reached at all — a light that dropped off the wifi is not the same thing as a light that's switched off. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.github` [#haussillbottomitemsgithub] `boolean or one of "left", "center", "right"` · default `false` One number from GitHub, and the rows behind it. The pill is configured as a list of typed SOURCES (`haus.sill.github.sources`) — a `search` filter, the `ci` board, or your own `command` — and its label is whichever source is worth interrupting you for: the highest-severity one with a nonzero count, earliest in the list on a tie. With nothing to report it draws no number at all rather than a zero, because a number you never act on is a number you stop seeing. LEFT-CLICK opens the dropdown, one section per source, each row clicking through to the PR or repo on github.com; RIGHT-CLICK refreshes now, as does the `Refresh` row at the bottom of the dropdown, which also says how old the numbers are. The `ci` source is the one thing gh-dash cannot show you: GitHub's search index carries no workflow runs, so "did main's last run pass" is only reachable as the check rollup of the default branch's head commit, in GraphQL — which is exactly what that source asks for, in one query for the whole owner. Needs `haus.developer.git.enable` (an assertion enforces it) for the `gh` it queries through, and a `gh auth login` you have run: not logged in, the pill says `auth` and its dropdown hands you that command rather than drawing a silent zero. Never fetches on the bar's tick — the tick renders a cache and detaches the network call — so a slow GitHub costs a stale number, never a stalled bar. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.harvest` [#haussillbottomitemsharvest] `boolean or one of "left", "center", "right"` · default `false` A Harvest time-tracking pill; needs a \~/.config/sketchybar/harvest\_secrets.sh you provide. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.hush` [#haussillbottomitemshush] `boolean or one of "left", "center", "right"` · default `false` The Hush (Do-Not-Disturb) pill. Needs `haus.hush.enable`; setting this moves the pill but does not enable the Hush room by itself. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.media` [#haussillbottomitemsmedia] `boolean or one of "left", "center", "right"` · default `false` The now-playing track — auto-hides when nothing plays, dims when paused, and counts DOWN instead of scrolling a title once the thing playing is longer than twenty minutes (a podcast or a video is one you already know the name of; what you keep glancing at the bar for is how much is left). The title scrolls for a few seconds after a track changes and then settles, so nothing moves in the corner of your eye forever; hovering brings the full title back. Gestures: left click the dropdown, RIGHT click play/pause, ⌥ next, ⇧ previous, ⌘ jump to whatever is making the noise, scroll to seek ±10s. That ⌘ click reaches the browser TAB, not just the browser: the track's title is matched against the open tabs through Safari's and Chromium's AppleScript tab APIs, and on a Firefox fork (Zen among them) — which expose no tab list at all, neither to AppleScript nor to accessibility — through Firefox's own open-tab search in the address bar. Both routes ask for a permission the first time they run, Automation for the scriptable browsers and Accessibility for the Firefox forks, and both quietly fall back to just fronting the app if you say no. The dropdown carries the cover when the source published one, a scrubbable position slider, and transport rows — plus, for a source with no cover, a small app-icon badge floating in its bottom-right corner. It reads the same system-wide session Control Center does, so it follows a browser tab as readily as Apple Music or Spotify, and its icon says what KIND of thing is playing: an app it recognises gets that app's glyph, a browser gets video or music depending on whether an album was published. It cannot say which SITE — no URL reaches the now-playing session and none of window titles, artwork shape or the session's pid can recover one, so a wrong YouTube glyph on a Netflix tab is a guess this deliberately doesn't make; `haus.sill.media.icons` is the override for a machine that knows better. SketchyBar's own `media_change` event has been dead since macOS 15.4, where Apple started requiring an entitlement to talk to `mediaremoted`; the pill is fed instead by `media-control`, which does the read from inside the entitled `/usr/bin/perl`. That is a private-framework route Apple could close in any point release — `media-control test` exits non-zero once it has. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.memory` [#haussillbottomitemsmemory] `boolean or one of "left", "center", "right"` · default `false` Memory in use, drawn as a graph pill. It counts what Activity Monitor counts — app memory + wired + compressed — and deliberately NOT the file cache: macOS fills idle RAM with cache on purpose, and the old reading counted that as used, which is why it sat near 90% on a machine doing nothing. The pill's COLOUR is the kernel's own pressure level (green normal, amber warning, red critical) rather than the percentage, because 60% of RAM in use is a Mac working correctly and a pill that goes amber for it is a pill you learn to ignore. LEFT-CLICK opens a dropdown with used/total, the cache, compressed and swap figures and then the biggest footprints per app, each row clicking through to that app's window. RIGHT-CLICK opens Activity Monitor on its Memory tab. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.volume` [#haussillbottomitemsvolume] `boolean or one of "left", "center", "right"` · default `false` Output volume / mute state. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.weather` [#haussillbottomitemsweather] `boolean or one of "left", "center", "right"` · default `false` The weather pill and its click-to-open forecast popover. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.bottom.items.wifi` [#haussillbottomitemswifi] `boolean or one of "left", "center", "right"` · default `false` The Wi-Fi status pill. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.horizon` [#haussillcalendarhorizon] `positive integer, meaning >0` · default `24` How far ahead the `calendar` pill looks, in HOURS. Nothing starting later than this makes it say anything but "No events". It is a limit on the PILL, not on the dropdown: the timeline still lists what's coming past the horizon, because a list you opened on purpose is allowed to tell you about Thursday. Example: ```nix 12 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.imminent` [#haussillcalendarimminent] `positive integer, meaning >0` · default `5` How many MINUTES either side of an event's start the `calendar` pill fills solid — accent background, dark type — for a window of twice this in total. Deliberately tied to the START and not to the whole meeting: five minutes before is "go now" and five after is "you're late", and they are the same fact. A pill that stayed filled for the event's full hour would just be a pill that is a different colour. Example: ```nix 2 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.joinHosts` [#haussillcalendarjoinhosts] `list of string` · default `[ ]` Extra hostnames to treat as conferencing links, on top of the built-in set (Google Meet, Zoom, Teams, Webex, Jitsi, Whereby, Chime, BlueJeans, GoTo, Around, Discord). Right-clicking the pill — or clicking a dropdown row — opens the first link in the invite whose host matches. Matching is on the HOST, and a bare registrable name also covers its subdomains (`zoom.us` catches `us02web.zoom.us`). That is why it isn't a substring search: every Google Meet invite also carries a `tel.meet` dial-in and a `support.google.com` footer, and looking for "meet" anywhere in the notes opens the phone-number page. Example: ```nix [ "meet.mycorp.example" ] ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.me` [#haussillcalendarme] `list of string` · default `[ ]` Addresses (or display names) that are YOU, dropped from the "with …" line in the dropdown. An attendee list that includes you is a list that tells you nothing — every meeting is "with you and Ana". Usually unnecessary: a CalDAV account's calendar is named for the address it syncs, so the pill takes the calendar names that look like email addresses as its answer and re-checks them every six hours. Set this when that guess misses — a local calendar, an alias you're invited under, or a second address on the same account. It ADDS to what was found rather than replacing it. Example: ```nix [ "you@work.example" ] ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.past` [#haussillcalendarpast] `positive integer, meaning >0` · default `24` How many HOURS of finished events the dropdown's `Done` band keeps. The band exists so the timeline has a floor to read up from — "what have I already been in today" is the context that makes "next" mean anything. The pill itself never looks backwards. Example: ```nix 8 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.preciseUnder` [#haussillcalendarpreciseunder] `positive integer, meaning >0` · default `12` Below how many HOURS the countdown carries minutes. Under it the pill reads "in 3h20m"; at or above it, "in 14h", "in 2d". A number you are reading as "not yet" doesn't need its minutes, and the digits it drops are the ones a long meeting name would have eaten. Example: ```nix 3 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.refresh` [#haussillcalendarrefresh] `positive integer, meaning >0` · default `15` How often the `calendar` pill re-reads your calendar, in SECONDS. This was 60, which is the worst possible number for a pill whose whole job is a countdown in minutes: the displayed number was up to a minute stale, so "in 1m" could mean the meeting started fifty seconds ago, and an event you had just accepted took a minute to appear at all. One read costs about 50ms of `icalBuddy`, so paying it four times a minute is cheaper than being wrong. Hovering the pill forces a read regardless of this, which is the case that actually matters — looking at it is the moment it has to be right. Example: ```nix 60 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.upcoming` [#haussillcalendarupcoming] `positive integer, meaning >0` · default `5` How many future events the dropdown's `Next` band lists, at most. The first of them is the one the pill is about, and the one drawn in a box. Example: ```nix 3 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.calendar.width` [#haussillcalendarwidth] `positive integer, meaning >0` · default `32` How wide the `calendar` pill's label is allowed to get, in CHARACTERS — not pixels. The label reads "in 12m · \"; anything longer is clipped to this, and sweeps past in full while you hover the pill. The countdown leads deliberately: the clip eats the END of a label, so the number the pill exists for has to sit in front of the part that can run long. It is a MAXIMUM, not a fixed size — a short event name still draws a short pill. Example: ```nix 16 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.clock.mode` [#haussillclockmode] `one of "full", "compact"` · default `"full"` The display mode for the clock pill: `full` (default, e.g. "Fri Jul 31 09:41 AM" with calendar icon) or `compact` (e.g. "Fri 31/7 9:41" without icon and trimmed spacing). Example: ```nix "compact" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.clock.monoFont` [#haussillclockmonofont] `boolean` · default `true` Whether the clock pill's date and time use `haus.fonts.mono.name`, like the rest of Sill. Disable this to use macOS's system UI font, whose zero has no dot and is easier to distinguish from an 8 at a glance. The calendar icon remains in the Nerd Font either way. Example: ```nix false ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.elgato.host` [#haussillelgatohost] `string` · default `""` Which Elgato Key Light the `elgato` pill toggles — a hostname or IP, optionally with a `:port` (the light's HTTP API is on 9123). Empty (the default) means discover it: the pill browses mDNS for `_elg._tcp`, caches what it found in `~/.local/state/nebelhaus/elgato-host`, and re-browses at most once a minute whenever the light stops answering — so a light that took a new DHCP address comes back on its own, without a rebuild. Pin this when you have more than one light, when the light has a static lease, or when mDNS is unreliable on your network. Example: ```nix "elgato-key-light-mini-57a3.local" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.enable` [#haussillenable] `boolean` · default `false` The SketchyBar menu bar. When off, the native macOS menu bar is kept (nebelhaus stops hiding it) and no bar is drawn. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.refresh` [#haussillgithubrefresh] `integer between 60 and 3600 (both inclusive)` · default `300` How often, in seconds, the `github` pill re-asks GitHub. The floor is 60 and it is enforced by the type rather than written down: this is the first pill in the bar that crosses the network, and GitHub's authed budget is 30 search requests a minute and 5000 GraphQL points an hour — shared with every other `gh` on the machine, `gh-dash` included. The pill never fetches on the bar's tick. The tick renders a cache and, if it has gone stale, detaches the fetch; so this is how old a number may be, not how long anything waits. Example: ```nix 900 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources` [#haussillgithubsources] `list of (submodule)` · see below What the `github` pill counts, in the order it prefers to speak about them. Each entry names exactly ONE kind — `search`, `ci` or `command` — and the pill owns the query for that kind. It is typed rather than one free-form query string because the two questions have no common shape. A `search` is a GitHub search filter and comes back as a count plus rows with a title, a repo and a URL; the CI board does not exist in that index at all — GitHub's search carries no workflow runs — and is only reachable as the `statusCheckRollup` of a default branch's head commit, in GraphQL. Handing the option a raw GraphQL query instead would move the problem rather than solve it: the result is an arbitrary tree, so the pill would also need paired jq paths for the count, the rows and the state, and every one of them would fail at runtime inside a bar plugin, where the only symptom is a pill that draws nothing. `command` is the escape hatch, and it is a command rather than a query for the same reason: it can do the fetching AND the shaping, and you can run it in a terminal to see why it is wrong. Example: ```nix [ { ci = true; } # red default branches { search = "org:hausfold is:pr is:open"; } # the working queue { search = "org:hausfold is:pr is:open status:failure"; # …and the red half of it title = "red PRs"; severity = "bad"; } ] ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.ci` [#haussillgithubsourcesci] `boolean` · default `false` Every repo the owner has, its default branch, and whether that branch's head commit is green — one GraphQL query for the whole owner, counting the ones that came back FAILURE or ERROR. Archived repos are skipped; a repo with no checks at all is not a failure and is not counted. This is the source that exists because search cannot answer it. It is also why the pill is a pill: it is the one GitHub question with no `gh-dash` tab, since a dashboard section IS a search filter. Example: ```nix true ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.command` [#haussillgithubsourcescommand] `null or string` · default `null` A command run through `bash -c`, printing one row per line as `\t` or `\t\t`, where state is `ok`, `warn` or `bad`. The count is the number of rows; a line that doesn't match the shape is dropped rather than drawn mangled. It runs from the bar's detached fetch, i.e. under launchd's environment and not your interactive shell: name binaries by absolute path or expect them missing. Host-only — a desktop may not set it, since it is arbitrary code rather than data. Example: ```nix "gh api /notifications --jq '.[] | \"warn\\t\" + .subject.title'" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.icon` [#haussillgithubsourcesicon] `string` · default `""` The glyph beside that section's heading, in the bar's Nerd Font. Empty takes a default for the kind. Example: ```nix "" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.limit` [#haussillgithubsourceslimit] `integer between 1 and 100 (both inclusive)` · default `8` How many rows this source may put in the dropdown. Also what a `search` asks GitHub for per page — there is no point paging in a hundred hits to draw eight of them. The count is unaffected: it is the real total, and the dropdown says how many rows it left out. Example: ```nix 5 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.org` [#haussillgithubsourcesorg] `string` · default `""` Which owner the `ci` source asks about. Empty (the default) follows `haus.git.org`, which is where it should normally come from — an owner that renames is then one word for the whole machine. Set it only to point one source at a second owner. Example: ```nix "hausfold" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.search` [#haussillgithubsourcessearch] `null or string` · default `null` A GitHub issue/PR search filter, exactly as you would type it into github.com's search box. The count is the search's own `total_count`, which can be larger than the rows shown — the dropdown says how many it left out rather than letting a truncated list read as a complete one. The string is literal: nothing interpolates `haus.git.org` into it for you, because a machine that reads several owners is the reason that option is allowed to be empty. Write the `org:` in. Example: ```nix "org:hausfold is:pr is:open" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.severity` [#haussillgithubsourcesseverity] `null or one of "info", "warn", "bad"` · default \`\`bad`for a`ci`source,`info` for the others` How much this source's count matters, which decides both its colour (`info` neutral, `warn` peach, `bad` red) and which source the PILL speaks for when more than one has something to say — highest severity first, then list order. It is per-source rather than global because the same kind means different things in two entries: `is:pr is:open` is a work queue and `is:pr is:open status:failure` is an alarm, and both are searches. Example: ```nix "bad" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.github.sources.*.title` [#haussillgithubsourcestitle] `string` · default `""` The dropdown section's heading. Empty derives one: the owner and "default branches" for `ci`, the filter itself for `search`. Example: ```nix "red on main" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items` [#haussillitems] `submodule` · default `{ }` Which SketchyBar pills to draw, one bool each. The core pills — `clock`, `weather`, `media`, `battery`, `wifi` — default true; the extras — the readouts `cpu`, `memory`, `volume`, `calendar`, `caffeinate` and the personal `agents`, `aiUsage`, `elgato`, `harvest` — default false. Set only what you want to change: haus.sill.items = \{ weather = false; # drop a default-on core pill cpu = true; # add an off-by-default readout caffeinate = true; # add the keep-awake controller }; A pill set false is never created (its update script doesn't run either). The hush (Do-Not-Disturb) pill is separate — it rides haus.hush.enable, not this set. It can still be moved to the second bar with `haus.sill.bottom.items.hush`. This is the MENU BAR's set, and it is one group: the movable pills all sit on the right, because its left is the workspace pills, the front app and the leader picker, and its center is kept clear — that is the one span a MacBook's notch covers when the bar is at the top, which is where it is by default. `haus.sill.bottom.items` mirrors these pills for the optional second bar, also accepts `hush`, and takes a side (`"left"` / `"center"` / `"right"`) rather than a bare bool; a pill named there moves down rather than being drawn twice. Example: ```nix { cpu = true; weather = false; } ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.agents` [#haussillitemsagents] `boolean` · default `false` A paw pill tracking your agent-worktree panes. The label always names the state worth interrupting you for — "2 ready" outranks "5 working", which outranks "1 done" — never a bare count you'd have to click to decode. Click for the per-agent breakdown, sorted the same way (waiting first, then working, then idle, longest-elapsed first within each), each block showing the client, how long it's sat in that state, and — when the pane's checkout is a `holt` lane — its repo and PR status: merged, `+N unshipped` (exactly what `holt reship` fixes), not yet landed, or a dirty-tree footnote. A summary header totals the counts once more than one agent is running. Left-click a row to jump to that pane, ⌥/right-click for a live `zellij subscribe` peek. Fed by each client's own lifecycle hooks, which all call `agent-state` (also installed as \~/.config/sketchybar/plugins/agents-hook.sh): Opencode's plugin and Codex's \~/.codex/hooks.json are written for you (Codex asks you to trust its hooks the first time it sees them), while Claude Code's four agent-state hooks stay yours to point at it in \~/.claude/settings.json — Claude owns that file and rewrites it, so haus merges in only the keys it must and never touches those four. (The two worktree hooks ARE declared, in hearth: they point at a haus-controlled path and self-heal on rebuild.) A row whose zellij pane is gone drops off by itself, which is what stands in for the session-end event Codex doesn't have. Dormant until a client fires. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.aiUsage` [#haussillitemsaiusage] `boolean` · default `false` A gauge pill showing AI usage (Claude Code/Codex subscription rate limits as %, or Opencode API token cost as daily $). Automatically shows whichever provider reported most recently. Click for expanded session/weekly limits and daily/monthly API costs with model breakdowns. Claude and Opencode are read off disk; Codex has no local usage data, so its row is polled from your ChatGPT account with the OAuth token in \~/.codex/auth.json (refreshed and rewritten in place) — no Codex login on the machine, no call is made. Claude's row is pushed by its statusline; the Codex and Opencode rows are pulled by the pill itself on a 3-minute TTL, so they stay current on a machine that never opens Claude at all. Claude and Opencode also get a `tokens` block in the dropdown — raw tokens moved today, this week, this month and all time (cache reads and all), two periods to a line so a full set reads as a 2×2, purely for the fun of watching the number climb. A period with nothing in it is left out rather than printed as a zero, so the block simply gets smaller, and a closing `∑ Everything` adds every provider up when more than one is reporting. It is a score, not a limit: nothing acts on it, and it never reaches the pill's own label. Claude's is summed from your transcripts on a 15-minute TTL behind an index, so only sessions that grew since the last pass are re-read; Codex has no row because it keeps no local history to count. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.battery` [#haussillitemsbattery] `boolean` · default `true` The battery pill. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.caffeinate` [#haussillitemscaffeinate] `boolean` · default `false` A coffee pill that prevents idle system sleep for 1/2/4/8 hours, a custom whole-hour duration, or indefinitely. The display may still turn off; closing a MacBook lid still sleeps it. Uses macOS's built-in `caffeinate`, so there is no extra package. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.calendar` [#haussillitemscalendar] `boolean` · default `false` The one meeting you have to be at next, and one gesture to join it. It reads "in 12m · Design review" — countdown first, because a label is clipped from the END and the number is the part you must never lose; below `haus.sill.calendar.preciseUnder` hours it carries minutes, above it just "in 14h" or "in 2d", and while an event is running it says "now · …" instead of going blank. For `haus.sill.calendar.imminent` minutes either side of the start the whole pill FILLS with the accent — a shape change rather than a colour change, so it catches the eye you aren't pointing at it. RIGHT-CLICK joins: it opens the event's conferencing link, found in the invite's url, location or notes (Meet, Zoom, Teams, Webex, Jitsi, Whereby and friends out of the box; `haus.sill.calendar.joinHosts` adds your own). LEFT-CLICK opens the day as a timeline — what's DONE in the last `haus.sill.calendar.past` hours, what's on NOW, and what's NEXT — each event carrying its day, clock time, length and who it's with, the next one boxed, and a `Join` affordance on every row that has a link. Your own address is dropped from the "with" line automatically: a CalDAV calendar is named for the account it syncs, so the pill can work out which attendee is you with no configuration (`haus.sill.calendar.me` for the cases where it can't). A name too long for the pill sweeps past only while you HOVER it — nothing here starts a marquee on its own — and `haus.sill.calendar.width` sets how much room it gets before that applies. Pulls in `ical-buddy` automatically and reads Calendar, so macOS prompts for Calendar access on first run. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.claudeUsage` [#haussillitemsclaudeusage] `boolean` · default `false` Deprecated alias for `aiUsage`. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.clock` [#haussillitemsclock] `boolean` · default `true` The clock pill, pinned to the far right. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.cpu` [#haussillitemscpu] `boolean` · default `false` Total CPU load, drawn as a graph pill: the last two minutes of it behind the number, because a percentage on its own can't tell a spike settling from a climb that started five minutes ago. The reading is a DELTA between samples — the `ps` sum this used to print is each process's average over its whole lifetime, which on a machine that has been up a week barely moves while every core is pinned. LEFT-CLICK opens a dropdown: the user/system split, the load average, then what's responsible, biggest first and aggregated per app so a browser's twenty helpers are one row; clicking a row focuses that app's window. RIGHT-CLICK opens Activity Monitor on its CPU tab. The rows can only cover processes you own, so anything root runs — `kernel_task`, `WindowServer` — lands in `everything else` rather than going quietly missing from the sum. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.elgato` [#haussillitemselgato] `boolean` · default `false` Toggles an Elgato Key Light on the local network. The light is found over mDNS (or pinned with `haus.sill.elgato.host`), and the pill draws dim when it can't be reached at all — a light that dropped off the wifi is not the same thing as a light that's switched off. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.github` [#haussillitemsgithub] `boolean` · default `false` One number from GitHub, and the rows behind it. The pill is configured as a list of typed SOURCES (`haus.sill.github.sources`) — a `search` filter, the `ci` board, or your own `command` — and its label is whichever source is worth interrupting you for: the highest-severity one with a nonzero count, earliest in the list on a tie. With nothing to report it draws no number at all rather than a zero, because a number you never act on is a number you stop seeing. LEFT-CLICK opens the dropdown, one section per source, each row clicking through to the PR or repo on github.com; RIGHT-CLICK refreshes now, as does the `Refresh` row at the bottom of the dropdown, which also says how old the numbers are. The `ci` source is the one thing gh-dash cannot show you: GitHub's search index carries no workflow runs, so "did main's last run pass" is only reachable as the check rollup of the default branch's head commit, in GraphQL — which is exactly what that source asks for, in one query for the whole owner. Needs `haus.developer.git.enable` (an assertion enforces it) for the `gh` it queries through, and a `gh auth login` you have run: not logged in, the pill says `auth` and its dropdown hands you that command rather than drawing a silent zero. Never fetches on the bar's tick — the tick renders a cache and detaches the network call — so a slow GitHub costs a stale number, never a stalled bar. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.harvest` [#haussillitemsharvest] `boolean` · default `false` A Harvest time-tracking pill; needs a \~/.config/sketchybar/harvest\_secrets.sh you provide. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.media` [#haussillitemsmedia] `boolean` · default `true` The now-playing track — auto-hides when nothing plays, dims when paused, and counts DOWN instead of scrolling a title once the thing playing is longer than twenty minutes (a podcast or a video is one you already know the name of; what you keep glancing at the bar for is how much is left). The title scrolls for a few seconds after a track changes and then settles, so nothing moves in the corner of your eye forever; hovering brings the full title back. Gestures: left click the dropdown, RIGHT click play/pause, ⌥ next, ⇧ previous, ⌘ jump to whatever is making the noise, scroll to seek ±10s. That ⌘ click reaches the browser TAB, not just the browser: the track's title is matched against the open tabs through Safari's and Chromium's AppleScript tab APIs, and on a Firefox fork (Zen among them) — which expose no tab list at all, neither to AppleScript nor to accessibility — through Firefox's own open-tab search in the address bar. Both routes ask for a permission the first time they run, Automation for the scriptable browsers and Accessibility for the Firefox forks, and both quietly fall back to just fronting the app if you say no. The dropdown carries the cover when the source published one, a scrubbable position slider, and transport rows — plus, for a source with no cover, a small app-icon badge floating in its bottom-right corner. It reads the same system-wide session Control Center does, so it follows a browser tab as readily as Apple Music or Spotify, and its icon says what KIND of thing is playing: an app it recognises gets that app's glyph, a browser gets video or music depending on whether an album was published. It cannot say which SITE — no URL reaches the now-playing session and none of window titles, artwork shape or the session's pid can recover one, so a wrong YouTube glyph on a Netflix tab is a guess this deliberately doesn't make; `haus.sill.media.icons` is the override for a machine that knows better. SketchyBar's own `media_change` event has been dead since macOS 15.4, where Apple started requiring an entitlement to talk to `mediaremoted`; the pill is fed instead by `media-control`, which does the read from inside the entitled `/usr/bin/perl`. That is a private-framework route Apple could close in any point release — `media-control test` exits non-zero once it has. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.memory` [#haussillitemsmemory] `boolean` · default `false` Memory in use, drawn as a graph pill. It counts what Activity Monitor counts — app memory + wired + compressed — and deliberately NOT the file cache: macOS fills idle RAM with cache on purpose, and the old reading counted that as used, which is why it sat near 90% on a machine doing nothing. The pill's COLOUR is the kernel's own pressure level (green normal, amber warning, red critical) rather than the percentage, because 60% of RAM in use is a Mac working correctly and a pill that goes amber for it is a pill you learn to ignore. LEFT-CLICK opens a dropdown with used/total, the cache, compressed and swap figures and then the biggest footprints per app, each row clicking through to that app's window. RIGHT-CLICK opens Activity Monitor on its Memory tab. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.volume` [#haussillitemsvolume] `boolean` · default `false` Output volume / mute state. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.weather` [#haussillitemsweather] `boolean` · default `true` The weather pill and its click-to-open forecast popover. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.items.wifi` [#haussillitemswifi] `boolean` · default `true` The Wi-Fi status pill. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.color` [#haussilllogocolor] `null or one of "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `null` The logo's resting colour, by Catppuccin name. `null` (the default) follows `haus.theme.accent`, which is almost always what you want — the pill is haus's own mark, so it wearing haus's own accent is the point. This is only the RESTING colour. `haus.sill.logo.status` paints over it while something needs attention, and the hover sweep runs from it and returns to it. Example: ```nix "teal" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.gestures` [#haussilllogogestures] `boolean` · default `true` What the logo pill does when clicked: | gesture | what it opens | | ------------ | -------------------------------------------------------------------------------------------------------------------------------- | | left click | the **haus menu** — System Settings, Activity Monitor, Lock Screen, Nix Config, Haus Settings, Rebuild System, Reload SketchyBar | | ⌘ left click | `haus rebuild`, straight into a floating terminal | | right click | the full pounce palette (⌘Space), which is what a bare click on this pill used to do | All three are drawn by **pounce**, so all three need `haus.pounce.enable` (which the nebelhaus desktop turns on). With pounce off they are silent no-ops and this option is the switch that says so out loud — turn it off and the pill stops responding to clicks entirely, rather than looking like an affordance that does nothing. The menu's rows are not reimplemented here: each one runs the palette command of the same name, so fixing one fixes both places. That is the whole reason the popup dropdown this replaces is gone — it was a second copy of five of these rows, and (having never been openable at all) a second copy nobody could check. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.icon` [#haussilllogoicon] `string` · default `""` The glyph in the far-left logo pill — the one that was an Apple menu until it was the nebelhaus cat-ears mark. Any single character your bar font can draw; the default is Nerd Font's `nf-fa-home` (`U+F015`), a solid house. It has to hold up at 28pt with a pill's padding around it, which rules out more glyphs than you would expect. In particular **`⌂` (`U+2302`), the hausfold mark itself, is drawn hairline-thin in JetBrains Mono and does not gain weight at Bold or ExtraBold** — it is in the font, it is on the list below, and beside the workspace pills it reads as a much lighter object than everything around it. A taste call, not a bug: if you want the literal mark, take it and raise `haus.sill.logo.size`. Six that hold up at bar size, most to least solid: | glyph | codepoint | what it is | | ----- | --------- | --------------------------------------------- | | `` | `U+F015` | `nf-fa-home` — solid house (the default) | | `` | `U+F46D` | `nf-oct-home` — outlined house at icon weight | | `` | `U+EB06` | `nf-cod-home` — the same, slightly rounder | | `⌂` | `U+2302` | the hausfold mark, hairline | | `` | `U+F302` | `nf-fa-apple` — the logo this pill replaced | | `` | `U+F313` | `nf-linux-nixos` — the snowflake | There is deliberately no way to point this at an image file. SketchyBar draws a `background.image` left-anchored, at a scale you have to hand-tune per asset, and applies no tint to it — so a picture here can follow neither `haus.theme.accent` nor the state colours below, and cannot sweep on hover. haus drew this pill as a PNG for a while and every one of those was a real limitation of it. Example: ```nix "⌂" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.size` [#haussilllogosize] `positive integer, meaning >0` · default `20` Point size of the logo glyph. Its own knob rather than the bar's `FS_ICON`, because the glyphs worth putting here have wildly different optical sizes: the default solid house wants 20, `⌂` needs 25 before it stops looking like a typo, and a Nerd Font apple wants 17. Example: ```nix 25 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.status` [#haussilllogostatus] `boolean` · default `true` Let the logo's colour report the health of the machine, so the pill says something without being clicked: | colour | meaning | | -------- | -------------------------------------------------------------------- | | accent | everything haus runs is up | | `yellow` | a newer haus is pinned upstream (needs `haus.sill.logo.updateCheck`) | | `red` | something haus runs is enabled but not running | Red is the one that matters. It is the same check `haus doctor` opens with — `nix-daemon`, plus each of AeroSpace / SketchyBar / pounce whose launchd job exists on this machine — and its whole point is that a wedged agent is otherwise invisible: the bar keeps drawing the last frame it painted, so a dead SketchyBar and a quiet one look identical. All of it is local, costs four `pgrep`s on a five-minute tick, and makes no network call. Yellow ranks below red and both outrank the accent, so the pill always shows the worst thing true about the machine. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.sweep` [#haussilllogosweep] `boolean` · default `true` Sweep the logo through the six hausfold accents — mauve, teal, green, yellow, peach, pink, the order the site runs them (nebelung → holt → perch → trill → pounce → nebelhaus) — while the pointer is over it, then settle back. It is the bar's copy of the mark on hausfold.co, where hovering the `⌂` turns a conic gradient of those same six through the glyph. SketchyBar cannot put a gradient inside a glyph, so the sweep IS the gradient: one colour at a time, animated. It only runs from the resting accent. A pill sitting at yellow or red has something to say, and a rainbow running over that is a pill saying two things at once — so hover does nothing until the state clears. Leader mode suppresses it for the same reason. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.logo.updateCheck` [#haussilllogoupdatecheck] `boolean` · default `false` Add the yellow "a newer haus is available" state to the logo pill. Off by default because it is the one part of the pill that leaves the machine: it asks GitHub for haus's current head (the same `git ls-remote` behind `haus status`) once every half hour, and a bar that phones home should be something you turned on. No effect unless `haus.sill.logo.status` is on. Example: ```nix true ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.media.artworkTint` [#haussillmediaartworktint] `boolean` · default `false` Colour the media pill's glyph from the current cover art instead of from what kind of thing is playing. The colour is the cover's average, SNAPPED to the nearest member of the Nebelung palette — so the pill picks up the mood of a record without ever drawing a colour that isn't in the theme. Off by default because it trades a stable meaning (pink is Music, green is Spotify, red is video) for a colour that changes every three minutes. Only sources that publish artwork can drive it, which is fewer than you would think: every Firefox-family browser publishes none at all, and the pill falls back to the kind colour for those. Example: ```nix true ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.media.collapse` [#haussillmediacollapse] `boolean` · default `false` Draw the media pill as its glyph alone, and reveal the title only while the pointer is on it. Worth having on a MacBook: the bar's centre span is under the notch, so every character of scrolling track title is rent paid out of the room the workspace pills and the front-app name need. The pill still hides itself entirely when nothing is playing — this is about the case where something is. Example: ```nix true ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.media.icons` [#haussillmediaicons] `attribute set of string` · default `{ }` Override the media pill's glyph, keyed by bundle id (`com.spotify.client`) or by KIND — one of `music`, `spotify`, `podcast`, `video`, `vlc`, `browser.video`, `browser.music`, `other`. A bundle id wins over a kind. This exists because of one hard limit: **nothing on the machine can tell you which site a browser tab is playing.** macOS's now-playing session carries no URL, window titles only ever name the FOREGROUND tab (the one playing audio is usually behind), Firefox-family browsers publish no artwork to shape-check, and the session's pid is the browser's parent process rather than the tab's. So the pill draws a neutral video glyph for a browser rather than guessing YouTube and being wrong on Netflix. If you know that on YOUR machine browser video means YouTube, say so: haus.sill.media.icons."browser.video" = "󰗃"; Example: ```nix { "browser.video" = "󰗃"; "com.apple.podcasts" = "󰦔"; } ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.media.width` [#haussillmediawidth] `positive integer, meaning >0` · default `32` How wide the media pill's title is allowed to get, in CHARACTERS — not pixels. Anything longer is clipped to this and swept past instead, so this is the knob for how much of the bar the now-playing title may rent. Narrow it on a MacBook, where the bar's centre span sits under the notch and every character of title is paid for out of the room the workspace pills and the front-app name need. `haus.sill.media.collapse` is the harder version of the same trade: no title at all until you hover. It is a MAXIMUM, not a fixed size — the pill still shrinks to fit a short title, so a wide setting costs nothing until something long plays. Example: ```nix 16 ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.sill.position` [#haussillposition] `one of "top", "bottom", "auto"` · default `"top"` Where the bar sits. `top` and `bottom` pin it there. `auto` flips it at runtime — `bottom` whenever an external display is attached (docked with the lid open, or clamshell), `top` on the built-in display alone — driven by a `display_change` hook, so the bar moves the moment you dock or undock, without a rebuild. The bar's height/pill offsets are tuned for the notch, which only exists at the top of the built-in display; at `bottom` there's no notch to tuck under, so `auto` conveniently keeps the notch case (`top`) on the notched screen and the plain case (`bottom`) on the external. Example: ```nix "auto" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . ## Launcher [#launcher] Pounce — the command palette, its daemon, its commands, and every Pounce setting haus exposes. ### haus.pounce [#hauspounce] The ⌘Space command palette. #### `haus.pounce.autoQuit.delay` [#hauspounceautoquitdelay] `integer or floating point number between 0.25 and 3600 (both inclusive)` · default `2` Seconds to wait after the last window closes before looking again and quitting. Load-bearing, not politeness: it is what tells "I'm done with this app" apart from "close this window, open another" — which is what a browser does when you close its last window and hit ⌘N. Anything open at the end of the wait, including panels and dialogs the ⌘Tab switcher wouldn't list, calls the quit off. Two seconds is the responsive end of that trade. It is deliberately not enough for a cold IDE reopening a project — that is a case for haus.pounce.autoQuit.exclude rather than for a delay you would feel on every app. Read once, when auto-quit arms — changing it bounces the pounce daemon on the next rebuild. Example: ```nix 5 ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.autoQuit.enable` [#hauspounceautoquitenable] `boolean` · default `false` Quit an app when you close its last window, the way Windows does it. macOS keeps a windowless app running, so every one of them is a ⌘Q you forgot; with this on, pounce notices the last window go away and asks the app to quit. *Asked*, not killed — it is the same Quit event ⌘Q sends, so an app with unsaved work puts its sheet up and stays. Nothing here can lose work that ⌘Q wouldn't. What it CAN do is stop background work you were keeping a window open for: close Docker Desktop's dashboard and Docker is asked to quit, which stops your containers. Media players, torrent clients and chat apps have the same shape — that class of app is what haus.pounce.autoQuit.exclude is for. Reads the same window snapshot as the ⌘Tab switcher, so it wants the same Accessibility grant (set haus.pounce.signingIdentity so it survives rebuilds) and shares the observers rather than taking its own. Without the grant it stays off and says so in the log rather than guessing. Off by default: this changes when your apps die, which is a thing you feel, and the muscle memory it suits is not everyone's. Unlike the rest of pounce's config, the auto-quit settings are read once — when the daemon arms them — rather than per open. So a rebuild that touches any of the three restarts the pounce daemon, which haus does for you; nothing here needs a log-out to land. Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.autoQuit.exclude` [#hauspounceautoquitexclude] `null or (list of string)` · default `pounce's own list — `\[ "com.apple.finder" ]\`\` Bundle ids never auto-quit. `null` leaves pounce's own default in place, which is `[ "com.apple.finder" ]` — Finder is the one app macOS runs windowless by design, and quitting it blinks the desktop out while it relaunches. A list you write **replaces** that default rather than extending it, so put Finder back in it unless you mean to drop it. `[ ]` really does mean nothing is excluded. Read a bundle id off any running app with `osascript -e 'id of app "Notes"'`. Read once, when auto-quit arms — adding an app here bounces the pounce daemon on the next rebuild, so the app stops being quit immediately rather than at the next log-in. Example: ```nix [ "com.apple.finder" "com.docker.docker" "com.spotify.client" ] ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.enable` [#hauspounceenable] `boolean` · default `false` The pounce command palette daemon (⌘Space) + the palette commands haus ships. Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.followSystemAppearance` [#hauspouncefollowsystemappearance] `boolean` · default `true` Let the palette follow macOS Light/Dark Mode instead of pinning one polarity: pounce gets the nebelung variant AND its latte counterpart at your haus.theme.contrast, as its `theme`/`themeLight` pair, and picks between them per open (no rebuild, no daemon restart). Honest scope: this makes pounce the one themed tool that does NOT follow haus.theme.flavor — a flavor pin is a *palette* choice, and asking to follow the system says the polarity is macOS's call. The contrast axis still applies to both halves. Every other themed tool keeps whatever flavor pins. false pins pounce to the flavor like every other port, which is exactly what it did before this option existed. Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.items` [#hauspounceitems] `attribute set of (submodule)` · default `{ }` Per-item palette settings, keyed by the item's own address. One entry is one row of the palette: hide it, give it a search shorthand, give it a key. "cmd:\" a command, by script name without .sh "app\:/Applications/Foo.app" an application, by path "mode:\" a built-in window — launcher, clipboard, emoji, screenshots, camera, filesearch Those keys are pounce's own address space (the same strings its frecency store and `pounce run` use), so a key written here is also what you'd type to invoke the thing from a script or another tool's binding. Hotkeys can be a single chord ("opt+e") or a LEADER SEQUENCE — steps separated by spaces, modifiers by "+", the notation Emacs and VS Code use: hotkey = "opt+space e"; # ⌥Space, then E hotkey = \[ "cmd+k" "cmd+c" ]; # the same thing, step by step The modifier-only laptop Fn/Globe key is the one special single-step value: hotkey = "fn". It needs Pounce's Accessibility grant, unlike a Carbon chord or leader sequence, and fires only when Fn is tapped alone. haus uses it for mode:emoji by default; set that item's hotkey to null to leave the Globe key to macOS. Sequences are worth knowing about on a tiling desktop: they open a namespace that structurally can't collide with the ⌥/⌘ chords prowl already claims, and they need no Accessibility grant (pounce grabs the second step as an ordinary global hotkey for a couple of seconds rather than tapping events). Two things this checks at build time, because both fail SILENTLY at runtime: a key that names no real item shape (a "mode:" typo binds nothing at all), and a chord already claimed by haus.keys.palette, haus.keys.leader, or a terminal binding (whoever registers first wins, and it isn't always the same one). What it can't check is whether `cmd:` names a command that exists — command scripts are discovered at runtime, so pounce warns about that itself when the daemon starts, and `pounce doctor` lists any binding that failed to arm. Example: ```nix { "app:/Applications/Ghostty.app" = { hotkey = "opt+t"; }; "cmd:brew-services" = { listed = false; }; "cmd:emoji" = { alias = "emo"; hotkey = "opt+e"; }; "mode:clipboard" = { hotkey = "cmd+shift+v"; }; } ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.items..alias` [#hauspounceitemsnamealias] `null or string` · default `null` A search shorthand, matched at a bonus over the item's real name — so "emo" can find the Emoji Picker without renaming it. Example: ```nix "emo" ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.items..caption` [#hauspounceitemsnamecaption] `null or string` · default `null` How this item reads on the cheatsheet page that lists your item hotkeys (⌘Space then ⇥, or the leader's `/`). Only used when the item has a `hotkey` — a row without a key has nothing to teach. Defaults to a name derived from the key, which is right often enough to leave alone: `mode:clipboard` becomes "Clipboard history", `app:/Applications/Ghostty.app` becomes "Ghostty", and `cmd:brew-services` becomes "Brew services". Set this when the derived name isn't what the palette actually calls the row — haus can't read a command's own `# pounce: name` header at evaluation time, so that one is a guess. Example: ```nix "Clipboard history" ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.items..hotkey` [#hauspounceitemsnamehotkey] `null or string or list of string` · default `null` A global chord, or a leader sequence, that invokes this item directly without opening the palette first. Modifier names follow pounce's spelling: cmd/command/super/meta · opt/option/alt · ctrl/control · shift. Whether the KEY name is one pounce can bind is not checked here (that vocabulary lives in the app); a chord it can't register is reported by `pounce doctor` rather than silently dropped. `fn` is the modifier-only exception: it uses Pounce's Accessibility-gated event tap, fires only on a lone tap, and suppresses macOS's stock Globe action while armed. Example: ```nix "opt+space e" ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.items..listed` [#hauspounceitemsnamelisted] `boolean` · default `true` Whether the item appears in the palette's list. Named `listed` rather than `enable` because that is precisely what it does: false removes the ROW, and a `hotkey` on the same item keeps working. It's how you hide a command you only ever want to reach by key — or clear the launcher of tools someone else on this Mac has no use for, which is the closest thing to a "pack" the surface has today. (It writes pounce's own `enabled` key.) Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.scale` [#hauspouncescale] `integer or floating point number between 0.8 and 2.0 (both inclusive)` · default `haus.ui.scale, held inside pounce's 0.8-2.0` How big the palette is drawn. Multiplies every size in pounce's UI — the launcher's rows, header, icons and action bar, and the panels behind it: the emoji grid, clipboard history, recent screenshots, camera peek, Find Files, the cheatsheet and the window switcher. Follows haus.ui.scale by default, so you rarely set this directly. It exists as its own option for the case where the palette wants a different size from the rest of haus — the launcher is read at arm's length for a second, not lived in like the terminal. pounce's own range is narrower than ui.scale's, so a machine at `ui.scale = 2.5` gets a palette at 2.0 rather than an evaluation error. Two things adapt on their own, which is why one number is enough: the launcher shows fewer rows when the scaled rows stop fitting on screen, and every panel's width is held inside the visible frame. That matters most alongside `haus.displays..uiScale` — a larger-text display mode and a larger palette multiply, and the palette is the one that would otherwise run off the edge. Example: ```nix 1.4 ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.signingIdentity` [#hauspouncesigningidentity] `string` · default `""` A code-signing identity in your login keychain — either its SHA-1 or (preferred) its full common name. The pounce daemon is re-signed with it so a macOS Accessibility (TCC) grant survives rebuilds. List yours: security find-identity -v -p codesigning Prefer a "Developer ID Application" identity passed BY NAME (e.g. "Developer ID Application: Jane Doe (TEAMID)"): its designated requirement anchors on the stable team OU, so the grant survives even a certificate renewal (the renewed cert keeps the same name/team but gets a new SHA — a hardcoded SHA would silently fall back to unsigned). This is also the identity the Homebrew build is signed with, so both install paths share one identity. An "Apple Development" cert works too but expires yearly and pins the specific cert, so it's less durable. Changing this once invalidates the existing grant (the requirement changes) — re-approve pounce in Accessibility a single time after. Leave empty to run pounce unsigned (the palette works, but auto-paste and other Accessibility-gated features stay off). Example: ```nix "Developer ID Application: Jane Doe (ABCDE12345)" ``` Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.windowMode` [#hauspouncewindowmode] `one of "default", "compact"` · default `"compact"` The palette's proportions. `compact` is narrower with tighter rows and keeps its list hidden until you type — haus's tuned look, and what it shipped before this option existed. `default` is pounce's roomier layout, which shows the top results the moment it opens. This is shape, not size: how BIG the palette is drawn is haus.pounce.scale. The two compose — a compact palette at scale 1.4 is still the compact layout, just readable from further away. Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . #### `haus.pounce.windowSwitcher` [#hauspouncewindowswitcher] `boolean` · default `true` Replace the stock ⌘Tab app switcher with pounce's MRU *window* switcher: tap ⌘⇥ to toggle to the last window (across workspaces), hold ⌘ and keep tapping ⇥ to walk older ones, type while holding to fuzzy-filter (frecency-ranked). Rows are gathered by AeroSpace workspace under a header each, and focusing goes through `aerospace focus --window-id` so a window parked on another workspace surfaces correctly. Because prowl is tiling here, a bare tap deliberately looks past the workspace you're on and takes the most recent window on a different one — with two panes tiled side by side the most recent window is one you're already looking at, so landing there wouldn't be a switch. Moving between visible tiles stays windowNav's focus keys; the skipped siblings are still the rows just below you in the list. Needs the daemon to hold an Accessibility grant — in practice, set haus.pounce.signingIdentity so the grant survives rebuilds. Without the grant the event tap can't install and stock ⌘Tab keeps working, so this default is safe on a fresh, not-yet-granted install. false leaves ⌘Tab native even when the grant is there. Declared in [`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix) . ## Shelf [#shelf] Perch — the file shelf that grows out of the notch to catch what you drag at it. ### haus.perch [#hausperch] The notch file shelf. #### `haus.perch.enable` [#hausperchenable] `boolean` · default `false` The perch notch file shelf, installed via the perch flake (copied to /Applications). Declared in [`modules/perch/options.nix`](https://github.com/hausfold/haus/blob/main/modules/perch/options.nix) . #### `haus.perch.followSystemAppearance` [#hausperchfollowsystemappearance] `boolean` · default `true` Let the shelf's palette follow macOS Light/Dark Mode instead of pinning one polarity: perch gets the nebelung variant AND its latte counterpart at your haus.theme.contrast, and picks between them itself — no rebuild, no relaunch. Same honest scope as the pounce option of the same name: with this on, perch does NOT follow haus.theme.flavor, because asking to follow the system says the polarity is macOS's call. The contrast axis still applies to both halves. Set it false to pin the shelf to theme.flavor like every other themed tool. Perch has no theme picker of its own — the shelf is a five-second surface with nowhere to put one — so this is the only word on its colors. Declared in [`modules/perch/options.nix`](https://github.com/hausfold/haus/blob/main/modules/perch/options.nix) . ## Focus [#focus] One quiet switch: Do Not Disturb, an optional status somewhere else, and your own hooks on both edges. ### haus.hush [#haushush] One quiet switch: Do Not Disturb, optional Slack status, and your hooks. #### `haus.hush.enable` [#haushushenable] `boolean` · default `false` The hush room: one quiet switch — bar pill, palette command, and a `hush` CLI — that turns macOS Do Not Disturb on/off (via the declaratively-bound symbolic hotkey 175, pressed synthetically), optionally sets your Slack status, and runs your hooks. Honest scope: hush flips the built-in Do Not Disturb, not named Focus modes, and it doesn't manage which apps break through — curate that once in System Settings. The keypress needs an Accessibility grant on whatever app invokes hush (palette runs inherit pounce's; grant sketchybar once for the pill). `hush doctor` walks the one-time steps. Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . #### `haus.hush.hooks` [#haushushhooks] `list of (absolute path or string)` · default `[ ]` Extra scripts run on every hush/unhush, each called with a single argument "on" or "off". Paths are copied into the store; strings are run as-is (so $HOME paths work). Failures are logged, never fatal — a broken hook can't wedge the toggle. Example: ```nix [ ./onair-light.sh "/Users/ada/bin/pause-music" ] ``` Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . #### `haus.hush.slack.enable` [#haushushslackenable] `boolean` · default `false` Also set a Slack status and snooze Slack notifications (all devices, phone included) while hushed. Off by default: it needs a personal Slack user token (scopes users.profile:write + dnd:write) provided via tokenCommand. The previous status is saved and restored on unhush. Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . #### `haus.hush.slack.snooze` [#haushushslacksnooze] `boolean` · default `true` Also pause Slack's own notifications (dnd.setSnooze) while hushed — this is what silences the phone. Ended on unhush; capped at 24h as a failsafe if you forget. Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . #### `haus.hush.slack.statusEmoji` [#haushushslackstatusemoji] `string` · default `":no_bell:"` Slack status emoji while hushed. Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . #### `haus.hush.slack.statusText` [#haushushslackstatustext] `string` · default `"heads down"` Slack status text while hushed. Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . #### `haus.hush.slack.tokenCommand` [#haushushslacktokencommand] `string` · default `""` Shell command that prints the Slack user token (xoxp-…) to stdout. Keychain-first so no secret ever lands in the store or a dotfile: security add-generic-password -s hush-slack -a $USER -w 'xoxp-…' Example: ```nix "security find-generic-password -s hush-slack -w" ``` Declared in [`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix) . ## AI [#ai] Coding agents: which clients this machine installs, the worktree lifecycle around them, and the instructions and `haus` skill every client reads. ### haus.ai [#hausai] The AI room: whether this machine runs coding agents at all, which clients it installs, which one the agent keybinding spawns, and the two files haus ships into every one of their homes — your instructions, and the `haus` skill. Spelled `haus.agents.*` before 2026-08-13, with the switch under `haus.developer.agents`; both are gone rather than aliased. #### `haus.ai.clients` [#hausaiclients] `list of (one of "claude", "codex", "opencode")` · default `[ ]` Which coding-agent clients to install. `claude` is Claude Code, `codex` is OpenAI Codex, `opencode` is OpenCode. The ⌘A terminal binding starts whichever one `ai.default` names — Claude Code through its own `--worktree` hook, the others through `holt new`. A list rather than one bool per client, matching `developer.languages` — a fourth client later doesn't change this option's shape. This is the option that makes `ai.default` honest. Naming a client you have not installed used to fail *at spawn time*, inside the pane, after the worktree already existed: a flash of `codex is unavailable`, and litter to reap. `ai.default` must now be a member of this list, so the same mistake fails the rebuild instead, with both values named. Override the package for a client the usual Nix way — an overlay on `claude-code`, `codex` or `opencode` — rather than dropping the client here and installing your own copy alongside; two derivations shipping the same `bin/` name collide in one profile. Ignored entirely when `ai.enable` is off — see `haus._ai.clients`, the resolved list every room actually installs from. Before step 4 this was an assertion instead ("clients are set but the room is off"), which was right while the list defaulted from the room's own switch and wrong afterwards: a desktop names the clients, so a host turning the room off would have had to blank the desktop's list as well to get a rebuild at all. One switch now removes the room, which is what "clean removal when disabled" means. Example: ```nix [ "claude" "codex" ] ``` Declared in [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix) . #### `haus.ai.default` [#hausaidefault] `one of "claude", "codex", "opencode"` · default `"claude"` The coding agent started by Pounce's **Spawn Agent** command, by the ⌘A / Super-a zellij binds and the `c` shell alias, and used to reopen worktrees with no client recorded yet. Each spawned worktree records its own client, so changing this affects new work but never reopens an existing Codex or OpenCode task in Claude. Must be one of `ai.clients` — see there. Only `claude` can make its own worktree (its native `--worktree` flag, which fires `holt hook create`); for `codex` and `opencode` ⌘A runs `holt new` instead, producing the same checkout, branch and registry entry from the outside. Resuming follows the client too: `codex` reopens its cwd-filtered `codex resume` picker, `opencode` continues its latest session for that cwd. All three share one `holt` branch/parking/reap lifecycle, and all three light up the `agents` bar pill and the zellij tab-bar badge — the opencode plugin and the codex hooks are written for you; only Claude Code's stay yours to wire, because Claude owns its own settings.json (see `haus.sill.items.agents`). Example: ```nix "codex" ``` Declared in [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix) . #### `haus.ai.enable` [#hausaienable] `boolean` · default `false` The AI room: coding-agent *tooling*. `holt` (agent worktrees), `agent-state` (the pane-status writer behind the `agents` bar pill and the zellij tab badge), the agent-worktree statusline, and the client config hearth writes (Claude Code's settings.json keys, opencode's agent-state plugin). Which clients get installed is `ai.clients`. On, this room brings its clients, `holt` and the lifecycle wiring on its own. What it adds to OTHER rooms it adds only when they are present: the ⌘A terminal binds and the `c` alias arrive with the terminal, the `agents` pill with the bar, the agent commands with the launcher. None of those rooms is switched on by turning this one on. Off is right for any machine not running coding agents — it's a large surface a non-developer never sees. The neutral default installs no clients; a desktop that selects this room names both `ai.clients` and `ai.default`. Was `haus.developer.agents.enable`, and the rest of this namespace was `haus.agents.*`, until 2026-08-13. Neither spelling is aliased — see modules/moved.nix for why. Declared in [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix) . #### `haus.ai.instructions` [#hausaiinstructions] `strings concatenated with "\n"` · default `""` Your always-on, cross-project operating context — the "instructions" slot every client has under a different name. Written once per client in `ai.clients`, to the path that client actually reads: `~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`. Write it client-neutrally: the same text reaches whichever agent the ⌘A pane spawns, so a line about a Claude-only skill or file path is noise to the other two. When set, haus prepends two short sections of its own — a note that the file is generated and where to actually edit it (with THAT client's path), and the `holt` worktree etiquette, since haus ships `holt` and that rule is what keeps it working — then your text. Empty (the default) writes nothing at all, for any client, so a hand-managed instructions file is never clobbered just to inject haus's note. If you set it and one of those paths already holds a file you wrote by hand, home-manager moves yours aside as `.backup` rather than refusing — quiet, so check for one before the first rebuild after setting this. With `ai.clients` empty (a machine haus installs no client on) every known client's path is written instead of none: the list being empty means haus installs none, not that no agent runs here. Example: ```nix '' # How I work Ship small, verified changes; ask before anything hard to reverse… '' ``` Declared in [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix) . #### `haus.ai.skill` [#hausaiskill] `boolean` · default `true` Install the `haus` skill for every client in `ai.clients`, so an agent asked to "install Slack" or "make everything bigger" edits your host file and runs `haus rebuild` instead of guessing at dotfiles and `brew install`. One copy per client, in the directory that client scans: `~/.claude/skills/haus`, `~/.codex/skills/haus`, `~/.config/opencode/skills/haus`. OpenCode also scans `~/.claude/skills` for Claude Code compatibility, and prefers its own copy when both exist — so a machine running both clients sees the skill once, not twice. The skill's option reference is GENERATED from the haus revision this machine is pinned to, so it can only ever describe options that actually exist here — and it is regenerated by `haus update`. It also carries this host's current state (which rooms are on, where the host file is) and a starter AGENTS.md + CLAUDE.md pair for your config repo — the rules in the first, a one-line import in the second, so a session opened there is oriented whichever client it runs. Unrelated to the clients' own settings, which follow `haus.ai.enable`. This is a plain file drop: with `ai.clients` empty — a machine haus installs no client on, which can still have one from npm or Homebrew — every known client's directory gets a copy rather than none. Set false to leave every client's skills directory alone. Declared in [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix) . ## Text expansion [#text-expansion] Snippets, and the engine that types them out for you. ### haus.snippets [#haussnippets] Text expansion via espanso. #### `haus.snippets.enable` [#haussnippetsenable] `boolean` · default `false` Text expansion via espanso: type a short trigger (say "@@") and it's replaced inline with a longer string (your email), in any app — browsers, Messages, and the terminal. espanso injects keystrokes, so it works where macOS's own text replacement doesn't (terminals, many Electron apps). Off by default: it installs the Espanso.app cask and needs a one-time macOS Accessibility grant (System Settings → Privacy & Security → Accessibility → enable Espanso) the first time it runs. haus runs the SIGNED app bundle rather than a nix-store binary on purpose, so that grant is keyed to a stable identity and survives reboots and nixpkgs bumps — you grant it once, not on every rebuild (and so the espanso troubleshooting window stops popping up at login, since that window only ever meant "the grant went missing"). Declared in [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix) . #### `haus.snippets.matches` [#haussnippetsmatches] `list of (submodule)` · default `[ ]` The expansion table — one \{ trigger; replace; } per snippet, written to \~/.config/espanso/match/default.yml. Only espanso's plain trigger→replace form is exposed here; for dynamic matches (dates, shell output, forms) drop a hand-written .yml alongside it in \~/.config/espanso/match/ — espanso loads every file in that dir. Example: ```nix [ { trigger = "@@"; replace = "ada@example.com"; } { trigger = "##"; replace = "+1 555 0100"; } ] ``` Declared in [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix) . #### `haus.snippets.matches.*.replace` [#haussnippetsmatchesreplace] `string` · no default What it expands to. Example: ```nix "ada@example.com" ``` Declared in [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix) . #### `haus.snippets.matches.*.trigger` [#haussnippetsmatchestrigger] `string` · no default What you type. Example: ```nix "@@" ``` Declared in [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix) . ## Security [#security] Touch ID for sudo, lock behaviour, the firewall, and where secret values come from. ### haus.lock [#hauslock] Whether waking this Mac needs a password, and how long the grace period is. Worth setting on any laptop that leaves the house. #### `haus.lock.requirePassword` [#hauslockrequirepassword] `null or boolean` · default `null` Require a password to wake this Mac from sleep or the screen saver. null (the default) leaves macOS's own choice alone. The one setting in this group worth turning on for ANY shared or portable machine — a family Mac, a laptop that leaves the house. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.lock.requirePasswordDelay` [#hauslockrequirepassworddelay] `null or (unsigned integer, meaning >=0)` · default `null` Seconds to wait after sleep/screen-saver starts before `requirePassword` actually locks the screen — macOS's "grace period". null (the default) leaves macOS's own choice alone. 0 locks instantly. Has no effect while `requirePassword` is null or false. Example: ```nix 5 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.security [#haussecurity] Security posture: the built-in application firewall and how strict it is. Off on a fresh Mac; the setting to turn on for a laptop that joins networks you don't own. #### `haus.security.firewall.allowSigned` [#haussecurityfirewallallowsigned] `null or boolean` · default `null` Let built-in, Apple-signed software receive incoming connections without asking. null (the default) leaves macOS's own choice alone. Has no effect while `enable` is null or false. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.security.firewall.allowSignedApp` [#haussecurityfirewallallowsignedapp] `null or boolean` · default `null` Let downloaded, signed third-party software receive incoming connections without asking. null (the default) leaves macOS's own choice alone. Has no effect while `enable` is null or false. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.security.firewall.blockAllIncoming` [#haussecurityfirewallblockallincoming] `null or boolean` · default `null` Block ALL incoming connections, including ones apps ask for (AirDrop, screen sharing, a dev server on your LAN). null (the default) leaves macOS's own choice alone. Has no effect while `enable` is null or false. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.security.firewall.enable` [#haussecurityfirewallenable] `null or boolean` · default `null` The built-in application firewall. null (the default) leaves macOS's own choice alone (off, on a fresh install). The "public Wi-Fi" setting: worth true for a laptop that leaves home, closer to unnecessary for a desktop that never does. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.security.firewall.stealthMode` [#haussecurityfirewallstealthmode] `null or boolean` · default `null` Don't respond to network probes (ping, closed-port connection attempts) at all, instead of replying "connection refused". null (the default) leaves macOS's own choice alone. Has no effect while `enable` is null or false. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.collar [#hauscollar] Touch ID for sudo — including inside a terminal multiplexer — and the passwordless-rebuild rule. #### `haus.collar.enable` [#hauscollarenable] `boolean` · default `false` The collar room: Touch ID for `sudo`, with `reattach` — the PAM shim that keeps the prompt working when sudo runs inside a terminal multiplexer (tmux/zellij/screen), where it otherwise beachballs. Off means macOS's stock password prompt everywhere, including for the rebuild below. Nothing else in haus depends on it. Declared in [`modules/collar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/collar/options.nix) . #### `haus.collar.passwordlessRebuild` [#hauscollarpasswordlessrebuild] `boolean` · default `false` Exempt system activation from authenticating at all: a sudoers rule granting NOPASSWD to `darwin-rebuild` and `haus-activate` at their stable /run/current-system paths. This is what makes `haus rebuild`, `haus rollback` and `bench try switch` a single uninterrupted command rather than one that stops for a fingerprint you already gave. Honest scope: this is a real root grant, and both commands take a path or flake ref you choose — so it means "anything I can build, I can activate as root, unprompted". That is the whole point (you already authenticated to build it), but on a shared or managed machine it's the knob to turn off. With it off, activation prompts via Touch ID (or a password when `enable` is false) and nothing else changes. Declared in [`modules/collar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/collar/options.nix) . ### haus.secrets [#haussecrets] Where secret values come from on this machine. #### `haus.secrets.provider` [#haussecretsprovider] `null or string` · default `"keyring"` The secretspec provider that supplies secret VALUES on this machine. The secrets room writes it to \~/.config/secretspec/config.toml as the default provider, so `secretspec run / check / set` work without flags. Any provider string secretspec accepts, URIs included: "keyring" (macOS login keychain — local, no accounts), "onepassword", "bws" (Bitwarden Secrets Manager), "gcsm" (Google Cloud Secret Manager), "awssm" (AWS Secrets Manager), "vault", "pass", "protonpass", "lastpass", "dotenv", "env", or a scoped URI like "onepassword://account\@vault". WHICH secrets exist is not declared here — that's each project's committed secretspec.toml. Cloud providers authenticate with their own credentials, configured outside Nix (e.g. `gcloud auth application-default login` for gcsm); that login is the one manual step on a new Mac. null skips writing the config file entirely — run `secretspec config init` yourself. Example: ```nix "gcsm" ``` Declared in [`modules/secrets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/secrets/options.nix) . ## Shared surfaces [#shared-surfaces] Surfaces more than one room reads: the app roster, the workspaces, the keys haus owns, the interface scale, the first-run tour. They belong to no single room because moving one into a room would make the others depend on it. ### haus.roster [#hausroster] One list of everything this machine has — apps, fonts, command-line tools. Each entry drives its launcher key, cheatsheet row, and installs it from whichever source it names: a Homebrew cask or formula, a Nixpkgs package, or the Mac App Store. #### `haus.roster` [#hausroster-1] `attribute set of (submodule)` · default `{ }` The one list of things this machine has, keyed by a stable id. It is the canonical, composable source for AeroSpace launcher keys, the SketchyBar pills, the pounce cheatsheet, Nebelung theme ports — and for the install itself, from any of four sources (`cask`, `brew`, `package`, `appStoreId`). Every field except the id is optional, and WHICH fields you set is what the entry means. Set `key` and it joins the launcher; set none of the launcher/workspace/install fields and it's install-only — which is how a font or a command-line tool lives in the same list as Slack instead of in a second one beside it. haus's own `homebrew.casks` / `home.packages` still work and still merge; you just shouldn't need them for an app. Which WORKSPACE an app owns is not a field here — it's `haus.workspaces..apps` naming this entry's id, so one workspace can hold several apps (a "comms" workspace with Slack, Mail and Messages) instead of baking "one app, one workspace" into this schema. See that option. Attribute-set entries merge across Nix modules, so a host, an imported file, and pounce's "Install App" command can each contribute one app without parsing or replacing a monolithic list. Set an entry's enable field to false to remove it, or override individual fields by app id. Example: ```nix { # Launcher app: leader s. Own workspace + pill come from putting # "slack" in a haus.workspaces entry's `apps` (see below). slack = { key = "s"; name = "Slack"; appId = "com.tinyspeck.slackmacgap"; cask = "slack"; }; # Install-only: no key, so no leader binding and no pill. framer = { cask = "framer"; }; orbstack = { package = pkgs.orbstack; }; biome = { package = pkgs.biome; scope = "system"; }; ical-buddy = { brew = "ical-buddy"; }; xcode = { name = "Xcode"; appStoreId = 497799835; }; } ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..appId` [#hausrosternameappid] `null or string` · default `null` Bundle id, used for the AeroSpace `on-window-detected` auto-assign rule (when this app is a member of a `haus.workspaces` entry), the `float` rule below, and the wake-time re-sort. null skips both — the app still launches, it just isn't herded anywhere or floated. Find one with `osascript -e 'id of app "…"'`. Example: ```nix "com.tinyspeck.slackmacgap" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..appStoreId` [#hausrosternameappstoreid] `null or signed integer` · default `null` Mac App Store numeric app id (the digits in its store URL), so an App Store app is declared in the same roster as everything else rather than in a comment. Recording it is always safe; INSTALLING from it is opt-in via `haus.appStore.install`, because the App Store is the one source that can't be fully automated: `mas` has no sign-in command, and it cannot buy a paid app for the first time. Free apps it can fetch; paid ones you purchase once in App Store.app and every machine afterwards can install them. Example: ```nix 497799835 ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..brew` [#hausrosternamebrew] `null or string` · default `null` Homebrew FORMULA that installs this entry, appended to homebrew\.brews. For the command-line half of the roster — a tool with no .app bundle, which usually means `key`, `name` and `workspace` are all null. Example: ```nix "ical-buddy" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..cask` [#hausrosternamecask] `null or string` · default `null` Homebrew cask that installs this app. When set, it's appended to homebrew\.casks so declaring the app also installs it. null means "already present / installed some other way" (e.g. Safari, Music). Example: ```nix "slack" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..enable` [#hausrosternameenable] `boolean` · default `true` Whether this app participates in the shared launcher roster. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..float` [#hausrosternamefloat] `boolean` · default `false` Always float this app's windows instead of tiling them — an AeroSpace `on-window-detected` rule generated from `appId` (`run = 'layout floating'`). Right for a picker/dialog/status window that would otherwise reflow the whole workspace every time it opens (FaceTime, Trill's Settings/Inbox), not for something you work inside. Requires `appId`; ignored (with a warning) without it. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..installedBy` [#hausrosternameinstalledby] `null or string` · default `null` The nebelhaus module that puts this app on disk, when none of the four sources above describes it: pounce and perch copy a notarized bundle into /Applications from their own activation step, which is neither a cask nor a package you can list. Set BY haus, not by you. It exists so the roster can still answer "who installed this?" for those apps — without it, a host adding a leader key for Perch had to KNOW haus already ships it, leave every source field null, and leave a comment explaining the hole. This is that comment, as data. Example: ```nix "haus.perch" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..key` [#hausrosternamekey] `null or string` · default `null` The leader letter for this app: tap Caps Lock then this key to launch/focus it. Must be unique across the roster, and not one of launch mode's own: `v` `e` `z` `,` `` ` `` `-` `=` `/` `1`-`4` `esc` and the arrows are taken, and a rebuild refuses them. null (the default) means the entry is INSTALL-ONLY: it still brings its cask/formula/package, but claims no leader key, no cheatsheet row, and no launch-mode bubble. That is what lets one roster hold both the apps you reach for by keyboard and the ones you just want on the machine (and fonts, and CLI tools). Example: ```nix "s" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..label` [#hausrosternamelabel] `null or string` · default `null` Cheatsheet caption for the leader key. null uses name. Example: ```nix "Slack" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..name` [#hausrosternamename] `null or string` · default `null` macOS application name, as passed to `open -a`. Required when `key` is set (the launcher has nothing to open otherwise); null is right for an install-only entry — a font, a CLI tool, or an app you launch some other way. Example: ```nix "Slack" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..order` [#hausrosternameorder] `signed integer` · default `1000` Roster order; lower values appear first. Ties are sorted by app id. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..package` [#hausrosternamepackage] `null or package` · default `null` Nixpkgs package that installs this entry. Where it lands is `scope`'s call. A shared desktop or app pack can't set this one — it needs `pkgs`, and a data-only desktop has no arguments. Use `packageName` there. Example: ```nix pkgs.orbstack ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..packageName` [#hausrosternamepackagename] `null or string` · default `null` The same source as `package`, NAMED rather than evaluated: an attribute path into nixpkgs, so "orbstack" means `pkgs.orbstack` and "python3Packages.black" means what it says. `scope` applies to it identically. This is the source a shared app pack can use — see `haus.apps.packs`, and `modules/apps/packs/writing.nix` for one. Without it a pack could install from Homebrew and the App Store but never from Nixpkgs, because reaching `pkgs` is exactly what the data-only format forbids — the one gap in the four sources. Set this or `package`, never both; and it counts as a source like any other, so pairing it with `cask` is the same mistake as pairing `cask` with `brew`. Example: ```nix "orbstack" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..scope` [#hausrosternamescope] `one of "user", "system"` · default `"user"` Which profile `package` installs into. * "user" (default): home-manager's `home.packages`. Right for anything you run as yourself — apps, editors, CLI tools. * "system": nix-darwin's `environment.systemPackages`. Installed once for the whole machine, so it's on PATH for root, for non-login shells, and for launchd jobs — which is what a tool invoked by a daemon, a `sudo` workflow, or an activation script actually needs. (It is about REACH, not about the package needing elevated privileges to install: `darwin-rebuild` runs under sudo either way.) Ignored when `package` is null — Homebrew has no such split. Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.roster..titleRegex` [#hausrosternametitleregex] `null or string` · default `null` Scope `float` to windows of this app whose title matches this regex (AeroSpace's `window-title-regex-substring`), instead of every window the app opens. null (default) floats all of them. Some apps' windows report their title only AFTER AeroSpace has already detected and tiled them once (a race, not a bug this option can fix) — Ghostty is the known case, which is why haus's own Ghostty float rule is hand-written in aerospace.toml rather than generated from the roster. If a title rule flaps, that race is almost certainly why. Ignored when `float` is false. Example: ```nix "^Picture in Picture$" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . ### haus.workspaces [#hausworkspaces] The named AeroSpace workspaces this machine declares, and which roster apps live on each. A workspace, not an app, owns its bar pill and leader throw — so several apps (a whole "comms" role) can share one. #### `haus.workspaces` [#hausworkspaces-1] `attribute set of (submodule)` · default `{ }` AeroSpace workspaces this machine names on purpose, keyed by the workspace id AeroSpace itself will use (any string it accepts as a workspace name — a single letter like `T`, or a word like `comms`). First-class rather than a field on an app: an app can only ever own one workspace if the field lives on the app, which makes a role workspace ("communication" = Mail + Slack + Messages) or a project workspace literally unrepresentable. Here, a workspace lists its own members instead. The four fixed numbered workspaces (1-4, leader/⇧+digit) are not part of this option — they always exist, independent of what any app claims. This option is for the NAMED workspaces app windows get herded onto. An entry with no `key` and no `apps` does nothing (a warning says so); one with `apps` but no `key` still gets a persistent workspace, a pill (with `icon`) and auto-herds its member windows, it just has no dedicated leader throw. Example: ```nix { # Role workspace: three apps, one pill, one throw key. comms = { key = "c"; icon = ":slack:"; apps = [ "slack" "mail" "messages" ]; }; # Single-app workspace: the common case, one entry each. T = { key = "t"; icon = ":ghostty:"; apps = [ "ghostty" ]; }; } ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.workspaces..apps` [#hausworkspacesnameapps] `list of string` · default `[ ]` `haus.roster` app ids that live on this workspace: each one's window auto-moves here (via its `appId`), opening any of them from the leader lands you here, and this workspace's `key` throw (above) sends the focused window here regardless of which member app owns it. An app id may belong to at most one workspace. A plain list, not wrapped in `lib.mkDefault` even where haus itself contributes to it (ghostty → workspace `T`, say) — list options MERGE across modules at equal priority but a `mkDefault` list is dropped whole rather than merged the moment anything else defines the same option, so a host adding a second app to `T` would silently lose ghostty's membership if haus's own contribution used `mkDefault` here. Override a single membership by dropping the app's id from your own list instead. Example: ```nix [ "slack" "mail" "messages" ] ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.workspaces..icon` [#hausworkspacesnameicon] `null or string` · default `null` The SketchyBar workspace-pill glyph. A sketchybar-app-font ligature like ":slack:" renders a logo; any other string is drawn in the bar's Nerd Font. null falls back to the workspace's own id. Example: ```nix ":slack:" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.workspaces..key` [#hausworkspacesnamekey] `null or string` · default `null` Leader then ⇧\ throws the focused window to this workspace and follows it there (AeroSpace's `move-node-to-workspace --focus-follows-window`). There is no bare \ binding for a workspace — that namespace belongs to `haus.roster` app launch keys, one of which can double as this workspace's "open something here" action by being one of its `apps`. null means the workspace is reachable only by launching an app that belongs to it (or not by keyboard at all). Must be unique across workspaces, and ⇧\ must not collide with a built-in launch-mode binding (⇧1-4 are taken). Example: ```nix "c" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . ### haus.ui [#hausui] One number for "make the interface bigger", applied across haus's own surfaces. #### `haus.ui.scale` [#hausuiscale] `integer or floating point number between 0.5 and 3.0 (both inclusive)` · default `1.0` One number for "make the interface bigger". 1.0 is haus as tuned; 1.35 is a comfortable large-print setting; below 1.0 tightens things up. It sets the DEFAULT of the sizes it drives, so anything you pin by hand still wins: haus.ui.scale = 1.5; # everything grows haus.fonts.mono.size = 18; # …except the terminal, pinned here What it currently moves: * the terminal font size (haus.fonts.mono.size) * the command palette, whole (haus.pounce.scale) — its rows, text and icons, and the emoji / clipboard / screenshots / camera / Find Files / cheatsheet panels behind it * the type in Sill's menu bar — pill labels, icons and popup rows — up to a ceiling; see below * the Dock icon size (system.defaults.dock.tilesize) * Finder's sidebar rows (NSTableViewDefaultSizeMode) — a threshold rather than a multiplier, and it is set at every scale: at or below 1.0 haus picks SMALL rows (more fits in a tiled window), above 1.0 it picks Apple's large ones * prowl's window gaps That list is pinned by `nix flake check`'s `scale-reach`, which fingerprints every surface it names at four scales — so a wire dropped in a refactor fails a check instead of quietly ceasing to arrive. Where it stops, and why it isn't a gap waiting to be filled: * Sill's bar HEIGHT. The bar is 36pt with 28pt pills so the pills sit inside the 32pt menu-bar band that macOS's own hover-reveal covers; taller pills poke out below it. That band is macOS's, fixed, and has no setting behind it — measured, not assumed. So the bar's type follows this option up to the largest that still fits a pill (1.25x) and then stops, silently: past that a machine simply gets the ceiling. The only way to make the whole bar bigger is to change what a point MEANS — the display's scaled resolution, below. * perch, the notch shelf. It sizes itself from the SCREEN — a fraction of the display's width, clamped — which is the right answer for a thing hanging off the notch, and it means NEITHER lever moves it: a scaled display shrinks the shelf's width in points by the same factor that makes a point bigger, so it stays the same physical size while everything around it grows. A large-print machine gets a normal-sized shelf, and there is no option here that changes that. * anything outside nebelhaus. macOS has no system-wide UI scale, so third-party apps follow only a display-resolution change. Worth knowing if you set both: this and `haus.displays..uiScale` MULTIPLY. A larger-text display mode leaves a smaller desktop in points, and this asks for bigger points inside it — so 1.4 on an already-scaled display is a bigger jump than 1.4 on the panel's default. Example: ```nix 1.35 ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . ### haus.keys [#hauskeys] The keys haus owns — the leader, the palette, the window-chord modifier — and anything extra you hang off the leader. #### `haus.keys.leader` [#hauskeysleader] `one of "caps", "alt-space", "none"` · default `"none"` What enters the launcher/leader mode — tap it, then a letter opens an app, a digit focuses a workspace, ⇧+either throws the focused window to that workspace and follows it there, an arrow navigates, `-`/`=` resizes. * "caps" (default): Caps Lock. AeroSpace can't bind Caps Lock itself, so haus remaps it to F18 with hidutil and binds that. * "alt-space": the leader without giving up Caps Lock. No remap at all. * "none": no leader. Caps Lock stays Caps Lock, launch mode is unreachable, and nothing is remapped — the setting for a mouse-first machine, or for a Mac you are handing to someone else. What the leader fronted is still reachable: apps through the palette, window moves through service mode's join-with and the palette's own commands. Workspace focus and the workspace throws go away with it — they live only in launch mode. The remap is re-applied at every activation and does not survive a reboot, so moving off "caps" ends it — at the latest, at next boot. Only meaningful with haus.prowl.enable (AeroSpace owns the modes). Example: ```nix "none" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.keys.leaderExtras` [#hauskeysleaderextras] `list of (submodule)` · default `[ ]` Extra launch-mode (leader) bindings beyond the app roster: tap the leader, then `key`, to run `command`. Use it for leader actions that aren't "launch an app" — a script, an AppleScript, opening a URL. Only meaningful with haus.prowl.enable and keys.leader != "none" (with no leader there is no launch mode to bind into). Example: ```nix [ { key = "enter"; command = "osascript -e 'tell application \"Things3\" to show quick entry panel'"; caption = "Things Quick Entry"; } ] ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.keys.leaderExtras.*.caption` [#hauskeysleaderextrascaption] `null or string` · default `null` The Launch Mode cheatsheet caption for this action. null falls back to the raw command, which is rarely what you want — set it. Example: ```nix "Things Quick Entry" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.keys.leaderExtras.*.command` [#hauskeysleaderextrascommand] `string` · no default The shell command run when the leader is followed by `key`; launch mode exits afterward. It's written verbatim into a small `/bin/sh` script that AeroSpace execs, so ordinary shell rules apply — `$HOME` resolves, and single quotes (an `osascript -e '…'`, say) are safe, which they would not be inlined into AeroSpace's own config. Example: ```nix "osascript -e 'tell application \"Things3\" to show quick entry panel'" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.keys.leaderExtras.*.key` [#hauskeysleaderextraskey] `string` · no default The AeroSpace key name pressed after the leader (e.g. "enter", "space", "period", or a letter). Must not collide with a roster app's key or a built-in launch-mode key (the digits 1-4, the arrows, `-`/`=`, `v`/`e`/`z`, `,`, `` ` ``, `/`, esc) — nor with the workspace throws, which are ⇧ + any of those digits or a roster letter ("shift-1", "shift-b", …). An assertion in modules/prowl catches a clash rather than letting one binding silently shadow another. Example: ```nix "enter" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.keys.palette` [#hauskeyspalette] `one of "cmd-space", "alt-space", "ctrl-space", "none"` · default `"none"` What opens the pounce command palette. Registered in-process by the daemon, so it's near-instant and doesn't go through AeroSpace. "cmd-space" (default) is the one value that also DISABLES Spotlight's own ⌘Space, because the two can't share it. Every other value leaves Spotlight alone — including "none", which hands the palette's job back to Spotlight entirely. That's a fix as much as an option: haus used to take Spotlight's ⌘Space away unconditionally, even where nothing claimed it. Only meaningful with haus.pounce.enable. Example: ```nix "none" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . #### `haus.keys.windowNav` [#hauskeyswindownav] `one of "alt", "ctrl-alt", "cmd-alt", "none"` · default `"none"` The modifier vocabulary for prowl's window chords — one setting rather than a bind-per-action, because what people need to move is the modifier, not the letters. It drives focus (`` + hjkl), layouts (`` + `/` `,`), fullscreen, moving a workspace to the next monitor (`⇧⇥`), and entering service mode (`⇧;`). Anything that names a workspace — focusing one, or throwing the focused window there — hangs off `leader` instead, not this option. "alt" (default) is ⌥. The alternatives are for **non-US keyboard layouts**, where ⌥+letter types accented characters — a machine that owns ⌥+letter is unusable on those, which is the concrete reason this option exists. Whatever you pick, AeroSpace claims those chords **globally**, so they stop reaching whatever owned them inside a terminal. The surface is small now that the workspace throws moved to the leader: only hjkl, `/` `,`, `f`, `⇧⇥` and `⇧;`, none of which a roster letter can land on — and `⇥` is free again, since workspace back-and-forth retired in favour of pounce's cross-workspace ⌘⇥ switcher. (Under "ctrl-alt" that used to bite — the throws were `⌃⌥⇧` + an app's roster letter, so an app on `a` silently ate hearth's zellij `Ctrl Alt Shift a` in-place-agent bind. That collision is gone.) Nothing on a stock macOS collides either: the only ⌃⌥ system hotkeys are input-source switching (⌃⌥Space, off by default) and hyper-F13. "none" drops the modifier chords entirely: no focus/layout chords, no service mode. Combined with `leader = "none"` that's a machine where the tiler tiles and the keyboard is left alone — mouse-first. The cheatsheet follows, so it never advertises a key that does nothing. Only meaningful with haus.prowl.enable. Example: ```nix "ctrl-alt" ``` Declared in [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix) . ### haus.tour [#haustour] The first-run tutor. #### `haus.tour.enable` [#haustourenable] `boolean` · default `false` The haus tour — a first-run tutor that walks the four moves (launch / navigate / resize / palette) as ONE quiet pill in the bar, advancing live as each move is detected. It never opens a window or steals focus: a fresh machine just shows a dormant "new here?" hint, clicking it (or `haus tour`, or ⌘Space → tour) starts the lap, right-click hides it forever. Detection reuses signals haus already fires (the leader-mode scripts) — no key logging, no Accessibility. Needs prowl + sill (it silently stays out of the bar without them); the ⌘Space step is dropped when pounce is off. Progress lives in \~/.local/state/nebelhaus — `haus tour reset` re-arms a finished tour. Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.tour.steps` [#haustoursteps] `null or (non-empty (list of (submodule)))` · default `null` A community-authored tour, in order. null keeps the built-in four-move nebelhaus tour unchanged; supplying a list replaces it, so a shared desktop can teach its own workflow without shipping scripts or reaching outside the `haus.*` option surface. Detection reuses signals haus already emits. `launch`, `workspace`, `navigate` and `resize` need prowl; `palette` needs Pounce and its palette binding. The module warns when a chosen detector's room is disabled. Authoring a tour is also the ONLY way to have one without prowl: the built-in lap is three leader moves plus the palette, so `tour.enable` on a machine with `prowl.enable = false` draws nothing at all. `desktops/everyday.nix` is the worked example — one step, the launcher. Example: ```nix [ { detect = "palette"; hint = "Press {palette}, type tour, then hit ↵"; } ] ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.tour.steps.*.detect` [#haustourstepsdetect] `one of "launch", "workspace", "navigate", "resize", "palette"` · no default The existing haus signal that completes this step: entering launch, navigate or resize mode; changing workspace; or running the Haus Tour command from Pounce (`palette`). The tour observes outcomes, never keystrokes. Clicking the pill still skips a step that cannot be detected in the current setup. Example: ```nix "palette" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . #### `haus.tour.steps.*.hint` [#haustourstepshint] `string` · no default The instruction shown in the tour pill for this step. Name keys with the placeholders `{palette}`, `{leader}` and `{leaderName}` rather than typing a chord: they expand to what THIS machine resolved, so a tour written once still teaches the right keys on a machine that moved `keys.palette` or `keys.leader`. A hardcoded "⌘Space" is wrong on that machine and the author never sees it — the consumer does. Example: ```nix "Press {palette}, type calendar, then hit ↵" ``` Declared in [`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix) . ## Your machine [#your-machine] The facts that are about you or this Mac rather than about a room — your commit identity, your region, this laptop's power behaviour. A shared desktop may not set them. ### haus.git [#hausgit] Your commit identity, plus the GitHub owner this machine's work lives under — set your own. It stays in [your host file](/docs/haus/internals/flakes/#your-config-is-a-thin-consumer). #### `haus.git.email` [#hausgitemail] `string` · default `""` Git user.email for commits. Example: ```nix "ada@example.com" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.git.name` [#hausgitname] `string` · default `""` Git user.name for commits (hearth wires it into home-manager). Example: ```nix "Ada Lovelace" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.git.org` [#hausgitorg] `string` · default `""` The GitHub owner whose repos this machine works on. An organisation, or your own account: GitHub's issue search treats `org:` the same as `user:`, so one option covers both (measured against both qualifiers, 2026-08-08 — the counts match). It exists because a gh-dash PR section is a GitHub search filter scoped by `org:`. Set this **and** `haus.hearth.ghDash.enable` and Hearth renders four PR tabs for that owner — the open / green / red / just-shipped work. On its own it does nothing: it is the dashboard's scope, not a feature of its own. Leave it empty (the default) and Hearth writes no PR tabs at all, so gh-dash keeps its own and a host composing a queue in `programs.gh-dash.settings` never fights one. Empty is the right answer for a machine that reads several owners at once: there is no single owner to render. The issue and notification tabs are unaffected either way — they ask who you are (`@me`, `is:unread`) rather than where you work, so the dashboard ships them regardless. Where it earns its keep is a rename: an org that changes name, or a repo set that moves between orgs, is one word here rather than one per tab. A host's `repoPaths` can follow the same word instead of repeating it — read it as `config.haus.git.org` from a darwin-level module, or as `osConfig.haus.git.org` from inside `home-manager.users.`, where `config` is home-manager's and carries no `haus.*` at all. Example: ```nix "nebelhaus" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.git.shellAliases` [#hausgitshellaliases] `attribute set of (null or string)` · default `{ }` Per-host additions and overrides for Hearth's built-in Git shell aliases. Values are shell command strings; null removes a built-in. Hearth deliberately owns a compact, framework-independent default set, so this changes only Git shortcuts and does not require a shell plugin manager. Example: ```nix { gst = "git status --short --branch"; # replace a built-in gsync = "git pull --rebase --autostash"; # add one gco = null; # remove one } ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . #### `haus.git.signingKey` [#hausgitsigningkey] `string` · default `""` GPG key id for signing commits/tags. Empty disables commit signing. Key material + any YubiKey/smartcard setup live outside Nix (gpg-agent + pinentry-mac). Example: ```nix "6F7BD6F43A7C1420" ``` Declared in [`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix) . ### haus.locale [#hauslocale] Language, region, units and keyboard layouts. What a machine in any language other than English needs — and the one room whose settings reach apps you already have open, because haus posts the change notification macOS itself posts. #### `haus.locale.hourFormat` [#hauslocalehourformat] `null or one of "12h", "24h"` · default `null` Force 12- or 24-hour time everywhere, overriding whatever `region` implies. null (the default) follows the region. System-wide, unlike `haus.menuBar.clock.format`, which is only the menu bar clock's own key. Setting both is fine and normal; setting only this one still changes the menu bar, because the clock has no opinion of its own until you give it one. Example: ```nix "24h" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.locale.inputSources` [#hauslocaleinputsources] `null or (list of string)` · default `null` The keyboard layouts available in the input menu, by input-source id (`com.apple.keylayout.*`). null (the default) leaves your layouts alone. List them with: ``` hausax input-sources --all ``` THIS ONE OWNS THE LIST. Unlike every other option in §5.6's groups, a non-null value here is exhaustive: layouts you don't name get disabled, because "add these and keep whatever else was there" makes a machine that can never remove a layout it once added. Non-keyboard input methods (emoji picker, press-and-hold) are never touched. Applied through the documented Text Input Sources API rather than by writing `com.apple.HIToolbox` directly. The plist route does work, but it resolves a layout by an English display name (`Swiss French`, not `SwissFrench`) next to a numeric id that is required and never validated — a table haus would have to hardcode and would get wrong for exactly the layouts nobody here tests. Example: ```nix [ "com.apple.keylayout.US" "com.apple.keylayout.German" ] ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.locale.language` [#hauslocalelanguage] `null or (list of string)` · default `null` Preferred languages, best first — the order System Settings ▸ General ▸ Language & Region shows. null (the default) leaves macOS's own list alone. Apps use the first entry they have a translation for, so a list is a fallback chain, not a single choice. TAKES EFFECT ON RELAUNCH: an app picks its language when it starts. Already-open apps keep the old one until you quit and reopen them, and the login window follows at next login. Nothing haus can post changes that — it is how bundle resources load. Example: ```nix [ "de-DE" "en-GB" ] ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.locale.metric` [#hauslocalemetric] `null or boolean` · default `null` Use the metric system, overriding whatever `region` implies. null (the default) follows the region. Writes BOTH keys macOS keeps for this (`AppleMetricUnits` and `AppleMeasurementUnits`), because it writes both itself and only one of them is load-bearing — setting the friendlier-looking `AppleMeasurementUnits` alone leaves a plist that reads right and a machine that ignores it. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.locale.region` [#hauslocaleregion] `null or string` · default `null` The region whose formats macOS uses — dates, number separators, paper size, the first day of the week. An ICU locale identifier (`de_DE`, `en_GB`, `fr_CA`). null (the default) leaves macOS's own choice alone. This is the lever with the most reach in the group: it moves the hour format, the measurement system and the first weekday together. Set it before reaching for the individual overrides below — and note there is deliberately no `firstWeekday` option, because macOS's own `AppleFirstWeekday` key is stored and then ignored (measured; it is the second dict-valued key in this domain found to do that). The region's own answer is the only one that applies. Example: ```nix "de_DE" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.locale.temperature` [#hauslocaletemperature] `null or one of "celsius", "fahrenheit"` · default `null` Temperature unit, overriding whatever `region` implies. null (the default) follows the region. Separate from `metric` because macOS keeps it separate — a metric machine reporting °F is a real combination, not a mistake. Example: ```nix "celsius" ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . ### haus.power [#hauspower] Sleep timers and Low Power Mode, said separately for battery and charger — which is the whole point, and why this is built on `pmset` rather than on nix-darwin's own power options. #### `haus.power.computerSleep.battery` [#hauspowercomputersleepbattery] `null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` Minutes of idleness before the Mac sleeps while on battery, or `"never"`. null (the default) leaves macOS's own choice alone. A desktop Mac has no battery profile to write, so `pmset` warns and the rebuild carries on — set the `charger` half there. Example: ```nix 10 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.computerSleep.charger` [#hauspowercomputersleepcharger] `null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` Minutes of idleness before the Mac sleeps while on the charger, or `"never"`. null (the default) leaves macOS's own choice alone. A desktop Mac has no battery profile to write, so `pmset` warns and the rebuild carries on — set the `charger` half there. Example: ```nix 10 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.diskSleep.battery` [#hauspowerdisksleepbattery] `null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` Minutes of idleness before the disk spins down while on battery, or `"never"`. null (the default) leaves macOS's own choice alone. A desktop Mac has no battery profile to write, so `pmset` warns and the rebuild carries on — set the `charger` half there. Example: ```nix 10 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.diskSleep.charger` [#hauspowerdisksleepcharger] `null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` Minutes of idleness before the disk spins down while on the charger, or `"never"`. null (the default) leaves macOS's own choice alone. A desktop Mac has no battery profile to write, so `pmset` warns and the rebuild carries on — set the `charger` half there. Example: ```nix 10 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.displaySleep.battery` [#hauspowerdisplaysleepbattery] `null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` Minutes of idleness before the display sleeps while on battery, or `"never"`. null (the default) leaves macOS's own choice alone. A desktop Mac has no battery profile to write, so `pmset` warns and the rebuild carries on — set the `charger` half there. Example: ```nix 10 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.displaySleep.charger` [#hauspowerdisplaysleepcharger] `null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` Minutes of idleness before the display sleeps while on the charger, or `"never"`. null (the default) leaves macOS's own choice alone. A desktop Mac has no battery profile to write, so `pmset` warns and the rebuild carries on — set the `charger` half there. Example: ```nix 10 ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.lowPowerMode.battery` [#hauspowerlowpowermodebattery] `null or boolean` · default `null` Low Power Mode while on battery. null (the default) leaves macOS's own choice alone. The setting with the clearest opinion in this group for a laptop: on for battery, off for the charger, is what most people want and almost nobody sets. Example: ```nix true ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . #### `haus.power.lowPowerMode.charger` [#hauspowerlowpowermodecharger] `null or boolean` · default `null` Low Power Mode while plugged in. null (the default) leaves macOS's own choice alone. Example: ```nix false ``` Declared in [`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix) . # Troubleshooting (/docs/haus/reference/troubleshooting) Almost every haus problem is one of a few known macOS quirks with a known fix. Start here, whatever the symptom: ```sh haus doctor # Nix, the Xcode tools, every GUI agent, cask drift, the # launcher's Accessibility grant, the theme ports and your # secrets, in one pass ``` Every agent also keeps a log in `/tmp`: `aerospace`, `sketchybar`, `sill-bottom` and `pounce` each write `.out.log` and `.err.log` there. Reading the error log is usually faster than restarting blind, and an agent that dies again ten seconds after a kick is telling you something that only shows up in it. ## ⌘Space does nothing [#space-does-nothing] Pounce is a launchd **user agent**. If it isn't answering, kick it. This is the clean recovery for *any* wedged agent, and the rest of this page reuses it with a different label: ```sh launchctl list | grep pounce # is it running at all? launchctl kickstart -k gui/$(id -u)/com.hausfold.pounce ``` If the palette opens but **clipboard and emoji paste** don't work, it's missing the Accessibility grant: ```sh pounce --request-accessibility # approve the system dialog pounce --check-accessibility # prints true once granted ``` Set `haus.pounce.signingIdentity` so that grant survives rebuilds; see [Granting Accessibility](/docs/pounce/install#grant-accessibility). If ⌘Space still opens **Spotlight**, log out and back in once so the symbolic-hotkey reassignment takes. Another hotkey tool (skhd, AeroSpace, Raycast) bound to the same key grabs it with an event tap *ahead* of Pounce, so every summon spawns a fresh client instead of hitting the daemon's fast path. Registration still reports success and the palette still opens, which is what makes it hard to spot. `pounce doctor` names the culprit and tells you what to unbind. One line of its output is wrong on a haus machine: if the daemon isn't running it suggests `brew services start pounce`, which is the advice for a standalone Homebrew install. Here the daemon is the launchd agent above; kick that instead. ## Windows won't tile [#windows-wont-tile] Almost always the **cold-boot race**: an agent that launched before the desktop session was ready parked itself instead of starting. Kick it: ```sh launchctl kickstart -k gui/$(id -u)/org.nixos.aerospace ``` The smaller ones: * **Scrambled after sleep, or a window on the wrong workspace?** Tap ⇪ then \` to re-sort everything to its roster home. * **A binding you changed didn't take?** A rebuild rewrites the config, but the live daemon holds the old one until it reloads. Force it with `aerospace reload-config`, or run **Reload AeroSpace** from the ⌘Space palette. ## The bar is blank or missing [#the-bar-is-blank-or-missing] ```sh launchctl kickstart -k gui/$(id -u)/org.nixos.sketchybar launchctl kickstart -k gui/$(id -u)/org.nixos.sill-bottom # if you run the bottom bar ``` If `/tmp/sketchybar.err.log` says `could not acquire lock-file … already running?`, a stray `brew services` copy of SketchyBar is holding the lock. Every rebuild evicts it, so `haus rebuild` is the fix. **Running, but stale** (a pill you added isn't there, or a colour change didn't land)? The easy fix is **Reload SketchyBar**, from the ⌘Space palette or from the haus menu on a left-click of the logo pill. By hand: ```sh sketchybar --reload ~/.config/sketchybar/sketchybarrc # the menu-bar bar sill-bottom --reload ~/.config/sketchybar/sill-bottomrc # the bottom bar, if enabled ``` `--reload` on its own doesn't mean "re-read your config"; it means *re-run the path you resolved at startup*, and that path is a symlink into `/nix/store` resolved once. So a bare reload can quietly replay the config from the generation the bar booted on while exiting 0 and logging success. Naming the path re-resolves it now. And the two bars are separate instances: a lone `sketchybar --reload` leaves the bottom one exactly as it was. If you'd rather not run a custom bar at all, `haus.sill.enable = false` brings the native macOS menu bar back. ## After a macOS upgrade, *every* agent is dead [#after-a-macos-upgrade-every-agent-is-dead] If the bar, tiling and ⌘Space all come up dead at once right after upgrading to **macOS 26 Tahoe or later**, the culprit is **Background Task Management**. Tahoe gates login items whose executable isn't Apple-signed, and every Nix agent launches through `/bin/sh -c "…"`, which BTM files under "unidentified developer" and can silently refuse to start. They register fine; they just never run. ```sh haus btm # no-op before Tahoe; on Tahoe+ it reads the BTM store and instructs ``` There is **no declarative fix**: the toggle lives in macOS's own BTM store, which has no CLI to set it. So it's a one-time manual step: in **System Settings ▸ General ▸ Login Items & Extensions**, scroll to **Allow in the Background**, find the entries named **`sh`** (subtitled *Item from unidentified developer*), switch them on and reboot. Already on but still blocked? Flip off, then on, to force a database write. You can read the store yourself at any time: ```sh sudo sfltool dumpbtm | grep -B2 -A8 -iE "nixos|hausfold|darwin-store" ``` Look for `disallowed`, and search all three names: the launcher registers under `com.hausfold.pounce` while tiling and the bar are still `org.nixos.*`, so a grep for `nixos` alone reports a clean store while the launcher is the thing that's blocked. ## Every agent is dead, and macOS didn't change [#every-agent-is-dead-and-macos-didnt-change] Same symptom, different cause, and this one needs no upgrade: **a rebuild that aborted before it got to them.** If your host file writes `system.defaults.universalaccess.*`, activation writes those keys two thirds of the way through, and without Full Disk Access that write fails, takes the rest of activation with it, and never reaches the launchd agents. The failure is nowhere near the symptom. `haus doctor`'s **Permissions** section reports the grant, `haus plan` says whether this rebuild needs it before you run one, and `haus rebuild` refuses up front whenever the app it runs under lacks it, agent pane or plain terminal alike, since the grant follows the app. Better still, sidestep the domain: `haus.accessibility.*` reaches every key in it that macOS actually honours (all seven, which is every key nix-darwin types there) with a guarded write, which costs you the setting and nothing else when the grant is missing. [Changing your Mac with an agent](/docs/haus/rooms/agent-rebuilds) has the detail. ## Touch ID for sudo beachballs inside a multiplexer [#touch-id-for-sudo-beachballs-inside-a-multiplexer] The `pam_reattach` shim isn't loading. It ships with the **collar** room and is on by default; check the order in `/etc/pam.d/sudo_local`: reattach must come *before* the Touch ID line. Outside tmux or zellij, Touch ID should just work; cancel the prompt to fall back to your password. [Touch ID](/docs/haus/rooms/security) has the rest. ## "Refusing to load cask … from an untrusted tap" [#refusing-to-load-cask--from-an-untrusted-tap] Third-party taps fail Homebrew's trust check under a sudo-driven activation. haus turns that requirement off globally (`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` in `/etc/homebrew/brew.env`), so seeing this means the file didn't land. Re-run `haus rebuild`, or export the variable in your shell for a one-off. ## An app I removed from my config is still installed [#an-app-i-removed-from-my-config-is-still-installed] By design. `haus.homebrew.cleanup` is `"none"`, so nothing you installed is ever auto-deleted, and **`haus rollback` doesn't rewind Homebrew apps**, because they aren't in Nix generations. Remove one by hand: ```sh brew uninstall --zap ``` For a machine where an undeclared cask *is* removed on the next rebuild, set [Customize a desktop](/docs/haus/desktops/customizing#homebrew-behaviour). ## A rebuild broke something [#a-rebuild-broke-something] ```sh haus rollback # atomically back to the previous generation haus generations # what you can roll back to ``` `haus rebuild` always builds *before* it switches, so a config with an error in it can't activate: you get a build failure and a machine exactly as it was. For macOS **settings** rather than packages, `haus revert-settings` puts back the snapshot `haus capture` took, and the APFS snapshot from install time is the coarser rewind. Want to go further than one generation (switch a room off, or remove haus entirely)? [Leaving](/docs/haus/leaving) walks each exit, smallest first. ## "Found a Nix at /nix that isn't Determinate" [#found-a-nix-at-nix-that-isnt-determinate] haus is built on [Determinate Nix](https://docs.determinate.systems/), and the installer stops on any `/nix` that Determinate didn't create (single-user or multi-user daemon install alike, it's the *flavour* it checks) rather than risk breaking the Nix you already have. If you have a Nix you don't need, uninstall it and re-run the one-liner; if you want to keep it, migrating to Determinate is the supported path. A fresh Mac needs none of this; the bootstrap installs Determinate for you. ## Still stuck? [#still-stuck] Still nothing? [Open an issue](https://github.com/hausfold/haus/issues) with the output of `haus doctor`. # Changing your Mac with an agent (/docs/haus/rooms/agent-rebuilds) Point an AI agent at a normal Mac and it `brew install`s things, writes dotfiles, and clicks through System Settings on your behalf. On a haus machine most of that would be silently undone by the next rebuild, with no record of what changed. Declarative config flips that. Every change is text in one file, `haus rebuild` **builds before it switches** so a broken edit never reaches the running system, and `haus rollback` puts the last one back in seconds. That makes yours the rare machine an agent can reconfigure without it being reckless. The safety was always there; the missing half was knowledge. So haus ships it. ## The skill haus writes [#the-skill-haus-writes] Every rebuild writes a **skill** called `haus` into the skills directory of each client in `haus.ai.clients`: `~/.claude/skills/haus` for Claude Code, `~/.codex/skills/haus` for Codex, `~/.config/opencode/skills/haus` for OpenCode. Name no clients at all (or turn the room off, which resolves to the same empty list) and all three get a copy, so the skill is never the missing piece. One body, one copy per client: | File | What it is | | ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SKILL.md` | The loop, the boundaries, the traps. | | `references/options.md` | Every `haus.*` option, **generated from the exact revision you're pinned to**. | | `references/this-machine.md` | Your host: hostname, host-file path, which rooms are on, your theme and keys, your app roster and its taken leader keys, plus the rebuild hazards specific to this machine. | | `references/recipes.md` | Worked examples for the common asks. | | `consumer-AGENTS.md` | A starter `AGENTS.md` for your own config repo. | | `consumer-CLAUDE.md` | Its one-line `@AGENTS.md` pointer, for the client that reads only `CLAUDE.md`. | The option reference being *generated* is the point: it can only describe options that exist on your revision (a confidently-wrong option name costs you a failed build), and `haus update` regenerates it with the rest. `haus doctor` has an **Agents** section: whether the skill is installed (it checks every client's directory and names the copy it found), whether your config repo has an `AGENTS.md` and the `CLAUDE.md` pointer beside it, and whether the app you're running from can write macOS's protected settings. ## The loop it follows [#the-loop-it-follows] **Orient**: read `references/this-machine.md` and your host file, and check `haus status`. **Find the option**: grep the generated reference. If it isn't listed, it doesn't exist on your revision, and the agent should say so rather than guess. **Edit `~/.config/nix/hosts//default.nix`**, the one file you own. **Apply with `haus rebuild`**: the build gates the switch. **Verify, or `haus rollback`.** ## What to ask for [#what-to-ask-for] Ordinary requests, in your own words: * *"Install Slack and give it its own workspace"* → an entry in `haus.roster` for the leader key and the Homebrew cask, paired with one in `haus.workspaces` for the workspace and its bar pill. * *"Everything's too small"* → `haus.ui.scale`. * *"Switch to light mode"* → `haus.theme.flavor` plus `haus.theme.systemAppearance`, so macOS's own Light/Dark moves with the rest. * *"Hide the weather in the bar"* → the matching `haus.sill.items.*` toggle. * *"Bind a key to open my notes"* → `haus.keys.leaderExtras`. The skill tells the agent to prefer a `haus.*` option over a raw nix-darwin setting, and to say so when nothing covers what you asked, because that's a gap in haus, not something to bolt onto your machine. ## What it won't do [#what-it-wont-do] * **Touch identity or secrets** without asking: git identity, signing keys, `haus.secrets.*`. * **Edit haus itself, `flake.lock`, or anything in `/nix/store`.** Pulling a newer revision is `haus update`, not a hand edit. * **Uninstall apps behind your back.** Removing an app from your config stops haus *managing* it; the app stays on disk until you `brew uninstall --zap` it. If your host file sets `system.defaults.universalaccess.*` directly, `haus rebuild` refuses to run whenever the app it runs under lacks **Full Disk Access**, and tells you how to get past it. That domain is TCC-protected, and the grant belongs to the app the rebuild runs under, not to you and not to root. So this is **not an agent rule**: an agent pane inside a terminal that holds the grant rebuilds perfectly well, and you at that same keyboard, in a terminal nobody has granted, are refused identically. Without the grant the write fails partway through activation and takes everything after it down too: your bar, tiling and palette come back dead, with the symptom nowhere near the cause. Refusing is the kinder failure: nothing is changed, and your edit is still on disk. The way past it is always to stop writing the domain by hand. `haus.accessibility.*` reaches every key in it that macOS actually honours (seven of them, which since August 2026 is **every key nix-darwin types there**, so the raw form no longer reaches anything the safe one doesn't) with a **guarded** write, so without the grant you lose that setting and nothing else, and the rebuild runs from anywhere. Otherwise: run it from an app that holds the grant, or set `HAUS_FDA_ANYWAY=1` when you know better (`HAUS_AGENT_REBUILD=1`, the name this had while the check was agent-scoped, still works). `haus plan` tells you which grant a rebuild wants *before* it runs, and `haus doctor`'s **Permissions** section says whether this app has it. `haus.theme.contrast` is a different thing again: haus's own palette axis, painted into the apps haus themes rather than into macOS, so it always applies. ## What a rollback doesn't undo [#what-a-rollback-doesnt-undo] `haus rollback` rewinds everything Nix manages, instantly. It does **not** rewind macOS system settings a rebuild wrote (Dock, keyboard, Finder), and it does not rewind Homebrew casks; those live outside Nix generations entirely. A good agent says which kind of change it's about to make. ## An AGENTS.md for your own config [#an-agentsmd-for-your-own-config] `~/.config/nix` is your own git repo, your machine in text. Dropping an `AGENTS.md` in it orients any agent you open there, whether or not it found the skill. A starter pair ships inside the skill; nothing writes it for you, because it's your repo: ```sh # whichever of these exists on your machine; the copies are identical: # ~/.claude/skills/haus · ~/.codex/skills/haus · ~/.config/opencode/skills/haus skill=~/.claude/skills/haus install -m 644 "$skill"/consumer-AGENTS.md ~/.config/nix/AGENTS.md install -m 644 "$skill"/consumer-CLAUDE.md ~/.config/nix/CLAUDE.md ``` `install -m 644`, not `cp`: the starter pair are symlinks into the Nix store, whose files are read-only. A plain `cp` preserves that mode, so you land on an `AGENTS.md` your editor refuses to save, on the one file the whole point of copying is to then edit. Two files, because the tools disagree about the filename. **`AGENTS.md` carries the rules**: Codex, OpenCode, Cursor, Zed, Copilot and everything else that speaks [agents.md](https://agents.md) reads it directly. Claude Code reads only `CLAUDE.md`, so the second file is a one-line `@AGENTS.md` import and nothing else. Copy one and you orient some of your tools and not others. ## Your own instructions, in every client [#your-own-instructions-in-every-client] `haus.ai.instructions` is the other half of what haus writes into an agent's home: your always-on operating context, rendered once per installed client at the path that client reads (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, `~/.config/opencode/AGENTS.md`), under a short generated preamble. ```nix haus.ai.instructions = '' # Global instructions Ship small, verified changes. Ask before anything hard to reverse… ''; ``` Write it **client-neutrally**: the same text reaches whichever agent your ⌘A pane spawned, so a line about one client's flags is noise in the other two. Empty (the default) writes nothing at all, so a hand-managed instructions file is never touched. *Setting* it on a machine that already has one, though, moves yours aside to `.backup`. And `haus.ai.skill = false;` stops the skill being written at all, for when you'd rather own each client's skills directory yourself. (Both were `haus.claude.globalMd` and `haus.claude.skill` until August 2026; the old names still evaluate, with a warning.) An agent that can rebuild your Mac can change how your Mac behaves. The safety here is real, but it is *recovery*, not prevention: the build gates the switch and the rollback is atomic, so mistakes are cheap, not impossible. Read the diff before you say yes, exactly as you would for any other change to your config. # AI (/docs/haus/rooms/ai) Run two coding agents in the same checkout and they fight: one switches the branch out from under the other, uncommitted edits collide, and closing a pane mid-thought can lose work. **`holt`** fixes that by giving every agent its own checkout, and haus puts it on your `PATH`, wired to a keybind. Press **⌘A** in any repo's terminal tab and you get a fresh agent session in an isolated git worktree: its own checkout on a `worktree-` branch, branched from the repo's local `HEAD`, living **outside** the repo under `~/.cache/claude-worktrees//` so your working tree stays clean and no build tool trips over it. **⌘⇧A** does the same in place of the focused pane rather than beside it; the pane it replaces is only suspended, so quitting the agent brings your shell back with its scrollback. **⌃⌥⇧A** is the resident variant: the one agent per tab allowed to edit the checkout you're looking at. holt calls what the first two make a **lane** (one agent's branch, checkout and terminal, as a single thing you can name), and that's the word its output uses. Not a *worktree*: a parked lane has no checkout on disk and is still a lane you can resume. (⌃⌥⇧A makes no lane at all; it's an agent in the checkout you already have.) Where a lane's terminal lives is the [Development](/docs/haus/rooms/development) room's call rather than holt's: `haus.hearth.lanes.backend` puts it in a zellij pane by default, or in a window of its own. It works on **any git repo**, and it isn't really a haus feature: it's [its own tool](https://github.com/hausfold/holt), which haus takes as an input and puts on your `PATH`. You don't have to be a haus user to run it. Closing a ⌘A pane doesn't just delete the checkout. Uncommitted edits are first parked as a `wip:` commit on the branch, and **only branches whose work has already merged get reaped**: everything unmerged survives on its branch, and the agent's transcript survives with it. The checkout is disposable; your work isn't. ## Enable it [#enable-it] ```nix haus.ai = { enable = true; clients = [ "claude" "opencode" ]; default = "claude"; }; ``` That brings the clients you named, `holt`, and the worktree lifecycle wiring. Nothing else in the house has to be on for it to work. ## Resuming [#resuming] Because the work is on the branch and the chat is in your client's transcript, a closed pane is never a dead end. Run `holt` bare to see everything you can pick back up, across every repo: ``` 🌫 lanes you can resume (holt , or /) repo name state agent last commit nebelung sparkle parked claude 2 hours ago — wip: auto-saved on pane close pounce quick-emoji live codex 10 min ago — add fuzzy weights ``` Name one and holt rebuilds the checkout and picks the conversation back up through whichever client that lane was made with: ```sh holt sparkle # resume it holt nebelung/sparkle # qualify when the name exists in two repos holt sparkle --pick # choose from that client's session list instead ``` By default it continues the newest conversation. `--pick` opens the client's own picker, for when a lane has more than one. ## Spawning from the launcher [#spawning-from-the-launcher] For a lane **named after the task** rather than the random `luminous-twirling-codd` an unattended agent would coin, summon the [launcher](/docs/haus/rooms/launcher) and run **Spawn Agent**: pick a repo, describe the job, and it creates the checkout and opens a session in that repo's zellij tab. Naming the branch from the prompt is the whole point. How you commit the prompt picks what happens next: | Key | Does | | ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `↵` | Spawn on what you typed | | `⇧↵` | Newline; a task is usually a list, not a sentence | | `⌘↵` | Grab a screenshot first, then spawn; cancel the capture and you're back in the box with your text intact. On an empty box it just attaches the shot and hands the box back | | `⌥↵` | Open your drafts; the box is saved on every dismissal, so a paragraph lost to a stray click is still here | Codex receives the screenshot as an image; Claude Code and OpenCode get its path in the first prompt. ## Which client gets spawned [#which-client-gets-spawned] ```nix haus.ai.clients = [ "claude" "opencode" ]; # what's installed haus.ai.default = "claude"; # what ⌘A spawns ``` `ai.default` must be one of `ai.clients`, and the rebuild says so by name if it isn't rather than failing later, inside the pane, after the checkout already exists. `clients` defaults to an empty list, which is exempt from that check (there's nothing to be absent from) and means haus installs no client itself. All of it hangs off one master switch, `haus.ai.enable`: holt on your `PATH`, the ⌘A keybinds, the tab marks, the statusline, the client wiring. Rooms are independent, so the switch is yours (or your desktop's) to set; hacker turns it on and names both options above. Turn the room off and `ai.clients` is ignored whole rather than half-wiring the machine, so a host can drop the room without having to blank the list its desktop set. holt records the client **with the lane**, so changing the option only affects new spawns: a parked Codex task still reopens through `codex resume --last`, and an OpenCode one through `opencode --continue`. Want a patched build of a client? **Overlay the package** (`claude-code`, `codex`, `opencode`) rather than putting your own derivation in `home.packages` beside it. Two derivations shipping the same `bin/claude` collide in one profile, and the rebuild fails with `two given paths contain a conflicting subpath`. ## Never `git stash`: park instead [#never-git-stash-park-instead] `git stash` looks like it belongs to the checkout you're standing in. It doesn't. The stash is one ref in the repo's shared `.git` directory, so **every worktree of a repo, and the main checkout, push and pop the same stack.** Two agents stashing at once means either can pop the other's entry into a tree that has never seen those files, and `stash@{1}` is no safer, being positional. So park instead: ```sh holt park "half-done refactor" # whole dirty tree → one wip: commit on THIS branch holt unpark # rewind it: changes back, uncommitted ``` `holt park` commits everything (tracked edits, untracked files, deletions) as a single `wip:` commit on the branch only this pane has checked out. Ignored files are never swept in, so parking can't smuggle a secret into history. It's the same thing the pane-close hook does, on demand, and `holt` lists the result as a resumable lane rather than a numbered entry in a shared pile. If the `wip:` commit has already been pushed (it's visible in an open PR), `holt unpark` refuses, because rewinding it locally turns a file restore into a force-push. It prints the escape hatch (`git reset --mixed HEAD^`) and leaves the commit alone. Parking on a branch that isn't an agent branch works, but says so: a `wip:` commit shouldn't drift onto a branch you push. ## Working on another repo: `holt child` [#working-on-another-repo-holt-child] Sometimes an agent in one repo has to change **another** one. Don't reach for a raw `git worktree add`: that skips holt's registry, so the statusline never learns to track the lane and its PR goes invisible. ```sh cd "$(holt child ~/code/other-repo)" ``` That makes a lane on the other repo, registers it as a **child of your current pane** so its PR shows up where you're working, and prints only the new checkout path, hence the `cd "$(…)"`. Pointing agents at haus itself rather than at your own projects? [Contributing](/docs/haus/internals/contributing) covers what's different in a family repo: building a lane's branch without merging it, and the one activation an agent is refused. ## Cleaning up [#cleaning-up] Most cleanup is automatic: closing a pane reaps a merged branch, and every `holt` listing self-heals first by sweeping parked branches whose PR has since merged. Some things end a pane without the graceful hook, though: a reboot, a crash, or a `holt child` checkout, which no pane owns. For those, sweep on demand: ```sh holt reap ``` `holt reap` is deliberately conservative. It leaves anything **dirty**, anything **unmerged**, and any checkout a **pane is still open in**: the one you're standing in included, and any other session's too, because a just-merged branch looks exactly like a finished one while its agent is still sitting in it. Only landed work nobody is standing in gets removed. Every deletion is written to a ledger before the branch dies, so a sweep is never the end of anything: `holt reaped` prints what went, why, and the `git branch` line that brings it back. And a lane that will *never* land (its PR closed unmerged, the repo archived) is `holt drop `, which waives the merged-branch requirement and nothing else. ## When a session outran its PR [#when-a-session-outran-its-pr] You merge the PR, keep talking to the agent, and it makes a few more commits. Those commits are now in an odd place: the PR that covered them is closed, and GitHub deleted the branch's remote counterpart when it merged, so they're committed, unpushed, unreviewed, on a branch nothing is watching. holt never reaps such a branch, and it says so everywhere: the listing marks the row **`live+3`** (three commits past the merge), `holt reap` names it instead of sweeping it, and the statusline shows an orange `3^`. One command clears it: ```sh holt reship # push this branch, open the follow-up PR holt reship sparkle # …or name one, from anywhere ``` It re-creates the remote GitHub deleted and opens a PR against the default branch, listing what's new. If a PR is already open, it just pushes to it. The lookalike is marked differently and wants the opposite treatment: **`~3`** means the branch has *diverged*: its tip doesn't build on what merged, so the checkout is stale rather than ahead. `holt reship` refuses it. Remove that lane instead of pushing it. ## Knowing which agent needs you [#knowing-which-agent-needs-you] Run agents in eight tabs and the question is always the same one: which of them is sitting on a permission prompt? The zellij tab bar answers it, with a coloured dot in front of the name of any tab holding agent panes: | Mark | Means | | --------------------------------------- | -------------------------------------------------------------- | | **`●` peach**, and the tab washed peach | an agent is blocked on you: a permission prompt, or a question | | **`◐` sky** | an agent is mid-turn, working | | **`○` green** | it finished its turn; nothing is waiting | | *(nothing)* | no agent panes in that tab | The dot shows the most urgent state in the tab, so peach always wins and the tab that needs you never hides behind one that's merely busy. (The peach wash is dropped on the tab you're already looking at.) It's the shape that carries the state, not the colour, and there's no count: how many agents a tab holds is the [bar](/docs/haus/rooms/bar)'s popup to answer, and the bar is the same signal for when you're out in the GUI rather than in the terminal. Both are reported by each client's own lifecycle hooks: the agent says what it's doing; nothing scrapes the screen for it. haus wires OpenCode's plugin and Codex's hooks for you. **Claude Code's four are yours to wire**, and haus deliberately leaves them alone. The **worktree** hooks that Claude Code's own `--worktree` flag fires: `WorktreeCreate` and `WorktreeRemove`, pointing at `holt hook create` / `holt hook remove`, plus the statusline, `permissions.defaultMode` and a few TUI keys. It merges into the file rather than replacing it, touches no other hook event, and re-asserts on every rebuild, because Claude Code rewrites its own settings on its own schedule and a hand-written hook can go with them. You'd find that out at pane close, by losing a lane's parking. ### The statusline HUD [#the-statusline-hud] haus points Claude Code's status bar at a small HUD driven off holt's registry, so you can see where every agent stands without leaving the pane. **Row one is this session's own lane**: a status token, its GitHub PR pill, then its name. **The rows below are the lanes this session spawned**, across whatever repos they live in, eight at most with a `+N more` line under them. Rows that still need attention sort above landed ones, so a clipped HUD can only ever hide something already reapable. The status token is one mutually-exclusive slot: | Token | Means | | ------------------------- | ----------------------------------------------------------------------------- | | `⏏` | the branch has landed; closing the pane reaps the lane | | `N^` | `N` commits on the branch, not merged yet | | `N^` *(orange)* | the PR merged and `N` commits landed since, covered by nothing; `holt reship` | | `+A -D` | uncommitted line changes, when nothing's committed yet | | `●` *(muted)* | nothing to report: clean tree, nothing ahead | Flush right on row one sit a few per-session chips: how far behind your pinned revision you are (`⇡N`, what `haus update` would pull), context used, session cost, the permission mode, and a short model tag. Context is coloured by **token count**, not percentage, because the percentage means different things on a 200k and a 1M model. Permission mode follows the same "no news is good news" rule as the status token: the default shows nothing, everything else is a positive mark. ## Command summary [#command-summary] | Command | What it does | | -------------------------- | ----------------------------------------------------------------------------- | | `holt` | List every parked and live lane across all repos (self-heals first) | | `holt ` | Resume one: rebuild its checkout, pick its conversation back up | | `holt reap` | Sweep every landed lane now (keeps dirty, unmerged, and any open pane) | | `holt reaped` | The ledger of what was swept, why, and the one line that brings a branch back | | `holt drop ` | Retire a lane that will never land: a PR closed unmerged, an archived repo | | `holt child ` | A lane on another repo, as a child of this pane | | `holt park [label]` | Set the dirty tree aside as a `wip:` commit; use instead of `git stash` | | `holt unpark` | Undo it: changes back in the working tree, uncommitted | | `holt reship [name]` | The PR merged and the branch kept going: push it, open the follow-up PR | | `holt new [name]` | A lane on **this** repo, with the default client opened in it | | `holt spawn ` | A named lane for a spawner with no pane of its own; what Spawn Agent calls | Add `--json` to the listing for a machine-readable version, `--version` for holt's own. ## Works with [#works-with] Three contributions, each appearing only when its receiving room is on, and none of them switching that room on for you: * **[Development](/docs/haus/rooms/development)**: the agent keybindings and pane lifecycle inside the multiplexer; * **[Bar](/docs/haus/rooms/bar)**: the agents pill, amber when a session is waiting on you; * **[Launcher](/docs/haus/rooms/launcher)**: **Spawn Agent**, which names the worktree after the job. ## Remove it [#remove-it] ```nix haus.ai.enable = false; ``` The clients, `holt` and the wiring go, including from a desktop that named clients, because the room's switch is what decides, not the list. What stays is everything on disk: the worktrees you created and their branches are ordinary git, and removing the room never deletes work. ## Options [#options] Every setting, with types and defaults: [AI](/docs/haus/reference/options#ai). # Appearance (/docs/haus/rooms/appearance) The whole system shares one palette: **nebelung**, a silver-mist Catppuccin variant. It is Catppuccin with the blue pulled out of the greys and the accents calmed down: a fog-grey scheme for people who find most dark themes too loud. Grey is the point. It is named for a cat breed the colour of high fog. nebelung renders that palette onto **50+ tools** (the terminal, the multiplexer, the prompt, git, the file manager, the default editor, the browser), so they all speak the same colours. Three knobs, in the order people reach for them: | Option | What it changes | | ---------------------------------- | ------------------------------------------ | | [`haus.theme.accent`](#the-accent) | the one hue that tints highlights | | [`haus.theme.contrast`](#contrast) | how far text separates from its background | | [`haus.theme.flavor`](#light-mode) | dark or light | They compose, and all three land on the next `haus rebuild`. ## The accent [#the-accent] The greys stay fixed; only the accent hue moves. ```nix haus.theme.accent = "sapphire"; ``` Any of the fourteen Catppuccin accent names works, and the default is `mauve`: `rosewater` · `flamingo` · `pink` · `mauve` · `red` · `maroon` · `peach` · `yellow` · `green` · `teal` · `sky` · `sapphire` · `blue` · `lavender` It re-tints the tools that render per-accent variants (lazygit borders, fzf highlights, yazi, glow's markdown headings, the Zen browser, and the [generated desktop](#the-desktop)), while single-file dotfiles keep their built-in theme. That list isn't prose anyone maintains by hand: a build-time check fails if a surface on it starts, or stops, following the accent. The accent moves the browser's own UI. It does **not** re-tint github.com, because those are Catppuccin *userstyles*, compiled inside the browser and stored in the Stylus extension rather than in any file haus writes. Declare the extension and haus closes the loop: ```nix haus.zen.extensions.stylus = { }; ``` It deploys Stylus through a macOS managed preference (`haus.roster` can't install add-ons) and stamps nebelung's userstyle bundle from *your* accent, flavor and contrast, so all three axes on this page reach the web. Because that preference is root-owned, Zen will say it is **managed by your organization**; that organization is your own config. Set `haus.zen.extensions.stylus.mode = "normal_installed"` to keep it removable. The import itself stays a click, because Stylus has no file interface: activation prints a one-time nudge, and you do **Stylus ▸ Manage ▸ Import**. ## Contrast [#contrast] The default greys are deliberately soft. If text doesn't separate enough (a bright room, a glossy screen, or eyes that want more edge): ```nix haus.theme.contrast = "high"; ``` Same hues, same accents; the neutral ramp is pulled apart in OKLCH so every step gains separation. Contrast is a property of the **neutrals** only, so a high-contrast machine is the same theme read more easily, not a different one. This recolours everything haus colours. macOS has its own switch, and a genuinely high-contrast Mac wants both: ```nix haus.accessibility.increaseContrast = true; ``` That one needs Full Disk Access on whatever you rebuild from. ## Light mode [#light-mode] ```nix haus.theme.flavor = "latte"; ``` Light mode is **not** the dark palette inverted. "Catppuccin with the blue stripped out" says nothing about dark, so the same recipe is applied to Catppuccin **Latte** instead of Mocha: the same warm-grey ramp and the same calmed accents, read the other way up. Because it is a real re-render, tools take their light-mode branches properly: ANSI black and white swap around, the browser switches its `prefers-color-scheme` block, delta marks itself light. The two axes give four palettes. Contrast of body text on the background is measured, not eyeballed, and nebelung's CI holds every one of them above the AAA floor: | | `contrast = "normal"` | `contrast = "high"` | | ------------------ | --------------------- | ------------------- | | `flavor = "mocha"` | 11.3:1 | 19.9:1 | | `flavor = "latte"` | 7.0:1 | 9.9:1 | By default haus leaves **System Settings ▸ Appearance** alone in both directions, so a light config on a dark Mac looks half-finished. One option finishes it: ```nix haus.theme.systemAppearance = "flavor"; # latte → Light, mocha → Dark ``` `"light"` and `"dark"` pin it instead; the default `"unmanaged"` is what leaves it alone, because a managed default would silently undo an appearance you picked by hand on your next rebuild. Driving it needs an **Automation** grant for whatever runs your rebuild (System Settings ▸ Privacy & Security ▸ Automation); without one haus tells you and changes nothing. And if Appearance is set to **Auto**, macOS switches polarity on its own schedule; haus sets it at rebuild time and doesn't fight it afterwards. **pounce and perch** are the exception in the other direction: both read their palette at runtime and follow macOS's Light/Dark themselves, so on a dark Mac they stay dark under `flavor = "latte"`. Pin them with `haus.pounce.followSystemAppearance = false`, needed only while macOS stays unmanaged. ## The desktop [#the-desktop] The wallpaper is generated on your machine rather than shipped as a picture, and it is what you get without saying anything: ```nix haus.wallpaper.style = "minimal"; # none | minimal | orbits | constellation | flow | bold ``` `minimal` is one flat colour out of your palette, the haus mark **⌂** at the centre in the family's six accents, and a very broad bloom in your own accent behind it. There is no photograph to clash with a terminal in front of it, and nothing that stops reading as a background once you have windows open. It is drawn from the palette, accent and gaps you already chose, which is why it can be the default. `style = "none"` is a real answer rather than an absence: nothing runs, and whatever wallpaper you have stays exactly where it is. The installer asks. | Knob | | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `haus.wallpaper.depth` | how far in from the palette's outermost tone the field sits, `0`–`5`. `1` (default) is one rung inside black in a dark config and inside white in a light one: a step below what your terminal draws on, which is what keeps a whole screen of it reading as a wall. `haus.wallpaper.background` overrides it with a hex. | | `haus.wallpaper.mark.*` | `enable`, `size`, `weight`, `opacity`, `rise`, and `color`: `spectrum` (default), `muted`, `ink` or `accent`. `spectrum` is the six-product sweep the ⌂ wears on [hausfold.co](https://hausfold.co), held still. | | `haus.wallpaper.glow.*` | the bloom: `color` (your accent), `strength`, `spread`. | | `haus.wallpaper.grain` | film grain, which is what stops the bloom banding into contour rings. `0` turns it off, and you only want that with `glow.enable = false`. | | `haus.wallpaper.size` | the pixel size to render at. Set it to your display's **native** count (`system_profiler SPDisplaysDataType` prints it); anything else is resampled, and resampling undoes the dither. | | `haus.wallpaper.debug.enable` | off by default: prints which revision of each family repo this machine was built from, small and dim at the bottom left, at exactly the inset a tiled window's corner lands on, so it only ever surfaces on a bare desktop. | Preview any of it without rebuilding a Mac: ```sh nix build github:hausfold/haus#wallpaper && open result ``` ## Apps you added yourself [#apps-you-added-yourself] Everything above covers the tools haus installs. Apps **you** put in your Apps you [added yourself](/docs/haus/rooms/apps) get the same treatment, as long as the id you keyed the entry under matches a nebelung port: ```nix haus.roster.zed = { key = "x"; name = "Zed"; appId = "dev.zed.Zed"; cask = "zed"; }; ``` On the next rebuild its theme is written where that app looks (`~/.config/zed/themes/`) in your current flavor, contrast and accent, and re-written whenever you change them. The key is the **roster id**, not the display name: `zed`, not `zed-editor`. `haus.theme.ports.enable = false` turns the whole pass off. Dropping a file is enough for apps that read a fixed path. Plenty of others (Xcode, Warp, OBS, JetBrains) have no file interface for *choosing* a theme, so the file is placed and the one-time pick stays yours. A few (Slack, Raycast) can't be installed by file at all. Rather than leave you to discover that months later, `haus doctor` says where each one stands: ``` 🌫 Nebelung theme ✓ gitui — themed (~/.config/gitui/theme.ron) ⓘ Xcode — placed at ~/Library/…/Catppuccin Mocha.xccolortheme — pick it in the app's settings ⓘ Slack — not installed — copy the hex string → Slack ▸ Preferences ▸ Themes ▸ paste ``` A ✓ is live now; a ⓘ tells you the one move left. Ports that would need merging into a config file you own (VS Code's `settings.json`) are **listed but never written**: silently half-editing your file is worse than telling you what to do. nebelung's [ports table](https://github.com/hausfold/nebelung/blob/main/docs/ports.md) publishes which is which. ## Obsidian [#obsidian] Obsidian keeps its theme *inside each vault*, so haus doesn't guess which folders are yours. List them as home-relative paths: ```nix haus.hearth.obsidianVaults = [ "Library/Mobile Documents/iCloud~md~obsidian/Documents/notes" ]; ``` Each listed vault gets the generated theme and has it selected; the rest of its appearance settings are preserved. A path without an `.obsidian` directory is skipped with a warning, so a typo can't create an empty lookalike vault. Obsidian is one of the places light mode doesn't reach yet; it stays dark. ## Using nebelung on its own [#using-nebelung-on-its-own] nebelung is a standalone colour system; you can theme any tool without the rest of haus. Copy a rendered theme out of the flake's output tree, or consume the flake: ```nix inputs.nebelung.url = "github:hausfold/nebelung"; # rendered themes: ${nebelung.packages.${system}.default}//... # raw palette: nebelung.palette # name → "#hex" ``` glow, lazygit, yazi and the browser render all fourteen accents as separate files; the other tools ship one theme with the default accent baked in. Every hex, and an interactive preview of all four variants, is in the [nebelung repo](https://github.com/hausfold/nebelung). Colours are generated at **build time** from that one source rather than loaded from loose theme files, which is why the palette can never drift out of sync: update nebelung, rebuild, and every tool gets the same new values at once. Want a different fog? The palette is computed in [OKLCH](https://oklch.com/) from three knobs (the grey's hue and chroma, and how far the accents are calmed), so hue and saturation can be rewritten while perceptual lightness is preserved. That is a change in the nebelung repo, not in your host file. ## Works with [#works-with] * **[Displays](/docs/haus/rooms/displays)**: `haus.appearance.largePrint` is one line for bigger type: it moves the interface scale, the contrast, macOS's own contrast lift and `displays.main.uiScale` together, each as a default you can still pin by hand. * **[Apps](/docs/haus/rooms/apps)**: anything you add whose id nebelung has a port for is themed too. * Every other room takes its colours from here rather than declaring its own. ## Permissions and side effects [#permissions-and-side-effects] Following macOS's own Light/Dark needs an **Automation** grant for whatever runs your rebuild (System Settings ▸ Privacy & Security ▸ Automation), as [light mode](#light-mode) describes. Theme *ports* write files into apps haus did not install; that's the point of them, and `haus.theme.ports.enable = false` stops it. ## Remove it [#remove-it] The room has no off switch, because a machine with no colours is not a thing anyone wants. The parts do: ```nix haus.theme.ports.enable = false; # stop writing themes into other apps haus.wallpaper.style = "none"; # keep the desktop picture you had haus.appearance.largePrint = false; ``` ## Options [#options] Every setting, with types and defaults: [Appearance](/docs/haus/reference/options#appearance). # Apps (/docs/haus/rooms/apps) Sooner or later you want Slack, or Discord, or `ripgrep`. Everything the machine has goes in **one list**, `haus.roster`, and you never choose an *option*, only which **fields** to set. A field says where the app comes from; another says what key launches it; an entry with neither is still a declaration. The quickest route doesn't involve editing anything: open pounce, run **Install App**, and pick from the curated shelf or search Homebrew, the Mac App Store, or your flake's pinned nixpkgs. It writes a small Nix module under `hosts//packages/`, rebuilds, and commits that one file. There is no `roster.json` behind it, just the same options you'd have written by hand. By hand: ```sh haus edit # opens ~/.config/nix/hosts//default.nix haus rebuild # build, then switch ``` ## Enable it [#enable-it] The Apps room is always present: the roster is a list, and an empty one is a machine with nothing extra on it. What you switch on are the curated extras: ```nix haus.apps.videoPlayer.enable = true; # IINA, and the file types it claims haus.apps.packs.writing.enable = true; # a saved collection, in one line haus.appStore.install = true; # let a rebuild install appStoreId entries ``` ## Where it comes from [#where-it-comes-from] Four source fields, one per package manager. Set **the one** that applies; setting two is a build error: | Field | Installs from | Example | | ------------ | --------------------------- | ------------------------- | | `cask` | a Homebrew cask | `cask = "slack";` | | `brew` | a Homebrew formula | `brew = "ical-buddy";` | | `package` | your flake's pinned nixpkgs | `package = pkgs.ripgrep;` | | `appStoreId` | the Mac App Store | `appStoreId = 497799835;` | Set none of them and the entry is pure metadata: right for something already on the machine (Safari, Music) that you still want a launcher key for. Don't know a name? `brew search --cask obsidian`, or `nix search nixpkgs ripgrep`. ## An app you'll live in [#an-app-youll-live-in] A keyed roster entry gives an app a Caps-Lock letter, a cheatsheet row, and the install. A [workspace](/docs/haus/rooms/windows#workspaces-and-what-lives-on-them) entry naming it gives it a workspace, a bar pill, and the rule that sends its windows there: ```nix haus.roster.slack = { key = "s"; name = "Slack"; appId = "com.tinyspeck.slackmacgap"; cask = "slack"; }; haus.workspaces.S = { key = "s"; icon = ":slack:"; apps = [ "slack" ]; }; ``` Rebuild, and then S lands you on Slack, on workspace `S`, every time. Two fields everybody looks up: * **`appId`** is the bundle id, which is how windows get sent to the right workspace. `osascript -e 'id of app "Slack"'` prints it. * **`icon`** goes on the *workspace*, not the app: a ligature from the [SketchyBar app font](https://github.com/kvndrsslr/sketchybar-app-font), like `:slack:`. Leave it out and the pill shows the workspace id. Keeping them apart is what lets one workspace hold several apps (a `comms` space with Slack, Mail and Messages) instead of one app per workspace. ## An app you just want installed [#an-app-you-just-want-installed] Omit the launcher fields. No letter, no cheatsheet row, no pill, but still **declared**, which is what keeps a later Homebrew cleanup from removing it: ```nix haus.roster = { discord = { name = "Discord"; cask = "discord"; }; figma = { name = "Figma"; cask = "figma"; }; }; ``` Casks are installed *by* the rebuild but aren't in Nix generations, so `haus rollback` neither uninstalls an app you added nor restores one you removed. Deleting the line doesn't delete the app either; `haus.homebrew.cleanup` is `"none"` on purpose. To actually remove one: `brew uninstall --zap `. ## An App Store app [#an-app-store-app] Recording the id is always safe; **installing** from it is opt-in, because it reaches the network and acts on your Apple Account: ```nix haus.roster.xcode = { name = "Xcode"; appStoreId = 497799835; # the digits in its App Store URL }; haus.appStore.install = true; ``` Three limits worth knowing before you rely on it. `mas` **cannot sign in**; do that once in App Store.app. It **can never buy a paid app**; buy it once, then any machine can install it. It **can** fetch a free app you've never had. An unpurchased paid app is reported and skipped during a rebuild, never fatal. A rebuild fetches these during activation, which is already running as root, so `mas get` neither prompts nor wedges. Going through pounce's **Install App** instead runs it in a visible floating terminal, because that path can be asked for an Apple Account. ## A CLI tool [#a-cli-tool] Same list, no launcher fields: ```nix haus.roster = { ripgrep = { package = pkgs.ripgrep; }; ical-buddy = { brew = "ical-buddy"; }; # when nixpkgs hasn't got it }; ``` `scope` applies to `package` only, and it is about **reach**, not privileges: * **`"user"`** (default): your profile. Right for anything you run as yourself. * **`"system"`**: machine-wide, so it's on `PATH` for root, launchd jobs and non-login shells. What a tool called by a daemon actually needs. To *try* something without installing it, hearth wires up `comma`: ```sh , cowsay "hello fog" # fetches it, runs it, keeps nothing ``` Reach for it twice and it has earned a roster entry. ## Every field [#every-field] | Field | What it's for | | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | The launcher letter, unique across the roster, and not one of launch mode's own: `V` `E` `Z` `,` `` ` `` `-` `=` `/` `1`–`4` `Esc` and the arrows are taken, and a rebuild refuses them. `null` (default) makes the entry install-only. | | `name` | The macOS app name, which is what `open -a` uses. Required when `key` is set. | | `appId` | The bundle id: auto-assigns windows to a workspace, and matches `float`. | | `label` | The cheatsheet caption. `null` uses `name`. | | `float` | Keep this app's windows floating instead of tiled, for a picker or status window that would reflow the workspace every time it opens. Needs `appId`. | | `titleRegex` | Scopes `float` to matching window titles instead of every window. | | `cask` · `brew` · `package` · `appStoreId` | Where it comes from. One of them, or none. | | `scope` | `"user"` (default) or `"system"`, for `package` only. | | `order` | Lower numbers appear earlier. | | `enable` | `false` disables a default or imported entry by id. | | `installedBy` | Set by haus, not by you: for an app copied in by its own activation step, which no source field can describe. | ## haus's own apps are entries too [#hauss-own-apps-are-entries-too] You don't have to know what haus already installs. Each room declares its own: prowl brings AeroSpace, sill brings SketchyBar, den brings Ghostty. So giving one of them a launcher key means writing only the launcher half: ```nix # perch's name, bundle id and installer come from haus.perch. haus.roster.perch.key = "p"; ``` The rule underneath: whoever installs an app owns its source fields, whoever gives it a key owns its launcher fields. Two modules never fight over one field, and your host file never restates what haus is doing. A handful of apps arrive because haus *chose* them for you rather than because a room needs them; those live in `haus.apps`, each behind one switch: ```nix haus.apps.videoPlayer.enable = false; # don't install IINA haus.apps.videoPlayer.claimFileTypes = false; # install it, rebind nothing ``` What it installs is an ordinary roster entry, so you can key it like any other. File-type claims are the same user default Finder's *Get Info ▸ Change All* writes, so nothing there is hard to undo. But **only one haus-owned app may claim a type**, or macOS asks you to pick a winner on every rebuild. If you add an extension to one list, check it against the other by UTI, not by spelling. If the id you key the entry under matches a tool nebelung has a port for (`zed`, `warp`, `xcode`, `gitui`, `kitty` and a few dozen more), its theme lands where that app looks for one on the next rebuild, in your flavor, contrast and accent. See [the Appearance room](/docs/haus/rooms/appearance#apps-you-added-yourself). ## Then apply it [#then-apply-it] `haus rebuild` builds first, so a typo can't take down a running system. New casks install, new tools land on `PATH`, and a new roster app gets its letter, workspace and pill together. If it doesn't tile on first launch, tap then \` to re-sort everything. Your host file is a plain nix-darwin module: anything nix-darwin or home-manager accepts works there and merges with haus. The roster is the common case, not the only door; [Making it yours](/docs/haus/desktops/customizing) is the rest of what that file can say. ## Works with [#works-with] * **[Windows](/docs/haus/rooms/windows)**: a roster id named in a workspace gets that workspace's pill and the rule that sends its windows there. * **[Launcher](/docs/haus/rooms/launcher)**: **Install App** writes a roster entry for you and rebuilds, so the common case needs no editor. * **[Appearance](/docs/haus/rooms/appearance)**: an app whose id nebelung has a port for is themed on the next rebuild. ## Permissions and side effects [#permissions-and-side-effects] `haus.appStore.install` is off by default: it reaches the network and acts on your Apple Account, and it can never be complete: `mas` cannot sign in and cannot buy a paid app. What a rebuild does to apps you *didn't* declare is [`haus.homebrew.cleanup`](/docs/haus/desktops/customizing#homebrew-behaviour), `"none"` by default, so nothing you installed by hand is ever removed. ## Remove it [#remove-it] Delete the roster entry and rebuild. Whether the app itself goes depends on that same `cleanup` policy: with the default `"none"` it stays installed and simply stops being declared. A pack switches off as one line. ## Options [#options] Every setting, with types and defaults: [Apps](/docs/haus/reference/options#apps). # Bar (/docs/haus/rooms/bar) **sill** replaces the macOS menu bar with a scriptable status bar, painted in your palette and wired to your tiling. While it's on, the native menu bar is hidden; the bar is the one place to glance. ## Enable it [#enable-it] ```nix haus.sill.enable = true; ``` ## What's on it [#whats-on-it] **Far left: the house.** Where macOS puts the Apple menu. It opens a menu, and its colour tells you whether the machine is fine. See [the logo pill](#the-logo-pill). **Left: your workspaces.** One pill each, showing the workspace's id or its icon, with the focused one highlighted and the front app's title alongside. Switch workspaces and the highlight follows; tap the Caps-Lock leader and the whole left side flips into launcher letters. **Right: the status cluster.** Clock, battery, Wi-Fi, weather and media, all on by default and each one hideable. More readouts are available and off by default; see [toggling pills](#toggling-pills). ## Dropdowns [#dropdowns] Several pills open a dropdown on click: weather, the calendar's timeline, the coffee pill's durations, the cpu and memory breakdowns. They behave like every other menu on the Mac: click elsewhere and it closes, click the pill again and it closes, open a second one and the first closes. The media pill is the exception: its left click is play/pause, so its dropdown is on **right**-click. ## The logo pill [#the-logo-pill] ### It opens a menu [#it-opens-a-menu] | Gesture | What you get | | --------------- | --------------------------------------------------------------------------- | | **Click** | System Settings, Activity Monitor, Lock Screen, your config, Rebuild System | | **⌘ click** | `haus rebuild`, in a floating terminal | | **Right click** | The full palette, the same thing ⌘Space opens | Every row of that menu *is* a palette command, so anything you can click you can also type. `haus.sill.logo.gestures = false` turns all three off, so the pill is plainly not a button rather than a button that does nothing. ### It tells you the machine is fine [#it-tells-you-the-machine-is-fine] | Colour | Meaning | | ----------- | ------------------------------------------- | | Your accent | Everything is running | | Yellow | A newer version is available: `haus update` | | Red | Something is enabled but **not running** | Red is the one worth having. A wedged background agent is otherwise invisible: tiling stops but leaves every window where it was, and a dead bar keeps the last frame it drew on screen. The check is local, free, and runs every five minutes. The **yellow** half asks GitHub whether a new version exists (the only thing this pill does that leaves your machine), so it's **off by default**. Turn it on with `haus.sill.logo.updateCheck = true`. Resting the pointer on the pill sweeps it through the six hausfold accents. It only runs at the pill's resting colour: a pill that has gone yellow or red has something to say and doesn't also get a rainbow. `haus.sill.logo.sweep = false` if you'd rather it sat still. ### Changing the glyph [#changing-the-glyph] ```nix haus.sill.logo.icon = "⌂"; haus.sill.logo.size = 25; # ⌂ is drawn thin; it needs the extra points haus.sill.logo.color = "teal"; ``` Left alone, the colour follows `haus.theme.accent`. The default is a solid Nerd Font house at 20pt. Whatever you pick has to hold up in a 28pt pill, which rules out more than you'd expect; the options reference lists six that work. You can't point it at an image file: a picture couldn't follow your accent or show any of the states above. ## The media pill [#the-media-pill] It reads the same system-wide session Control Center does, so it follows a browser tab as readily as Apple Music, and it hides itself entirely when nothing is playing. | Gesture | What it does | | ----------- | ------------------------------------------ | | Left click | Play / pause | | Right click | The dropdown | | ⌥ click | Next track | | ⇧ click | Previous track | | **⌘ click** | **Bring the app making the sound forward** | | Scroll | Seek ±10s | | Hover | Sweep a long title, once | That ⌘ click is the one to remember: it's the answer to *something is making noise and I can't find the tab*. Two habits. A long title never scrolls on its own, only on hover, and a sweep always finishes once started. And anything over twenty minutes counts **down** (`-38m`) instead of scrolling a name you already know. The dropdown carries cover art, title, artist, album, and a position slider you can click to scrub. ```nix haus.sill.media.collapse = true; # glyph only until you hover, good on a notched Mac haus.sill.media.width = 16; # characters of title before it sweeps. 25 by default. ``` `width` is a maximum, not a fixed size: a short title still draws a short pill. ## Toggling pills [#toggling-pills] Every right-side pill is one boolean. The five core pills (`clock`, `weather`, `media`, `battery`, `wifi`) default **on**; everything else defaults off. Set only what you want to change: ```nix haus.sill.items = { weather = false; # drop a core pill cpu = true; # add an off-by-default readout caffeinate = true; }; ``` A pill set `false` is never created; its update script doesn't even run. ### The extra readouts [#the-extra-readouts] | Pill | What it is | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `cpu` | Load, drawn as a graph of the last two minutes. Hover splits user vs system; click lists what's responsible, per app; right-click opens Activity Monitor. | | `memory` | Memory in use, as a graph. Coloured by the kernel's own pressure level, not the percentage: 60% in use is a Mac working correctly. | | `volume` | Output volume and mute state. | | `calendar` | Your next meeting and one gesture to join it. Reads `in 12m · Design review`; the whole pill fills five minutes either side of the start. **Right-click joins** the conferencing link; left-click opens the day as a timeline. | | `caffeinate` | A coffee pill: click for 1, 2, 4, 8 hours or until stopped; right-click stops. Survives a bar reload. | | `agents` | Tracks your [agent](/docs/haus/rooms/ai) panes: amber when one is waiting on you, click to jump to it. | | `aiUsage` | How much of your AI usage you've spent, as a gauge. Click for every provider's windows and reset times. | | `elgato` | Toggles an Elgato Key Light on your network. | | `harvest` | Harvest time tracking. Reads credentials from a file you provide. | The keep-awake engine is also a command, with or without the pill: ```sh awake 3h awake indefinitely awake off ``` The **hush** (Do Not Disturb) pill is separate: whether it exists is `haus.hush.enable`, not `haus.sill.items`. Where it goes is still a bar setting, which is why it can appear in `haus.sill.bottom.items` below. sill never bakes a token into the build. The Harvest pill sources your own credentials file at runtime; `aiUsage` reads your existing Codex login the same way. Keep those files out of git. ## A second bar along the bottom [#a-second-bar-along-the-bottom] If you want more room, or you'd rather keep the readouts off the menu bar entirely, sill can draw a second bar along the bottom of the screen at the same time: ```nix haus.sill.bottom = { enable = true; items = { weather = "left"; media = "center"; hush = "right"; }; }; ``` A pill named here **moves**: it's drawn on the bottom bar and not the top one, whatever `haus.sill.items` says. Never two copies racing each other. The value is where it goes: `"left"`, `"center"` or `"right"`; `true` means `"right"`. The menu bar only has one usable group (its left is the workspaces, its middle is under the notch on a MacBook), so the bottom strip is where all three are yours. Three things before you turn it on: * **macOS reserves nothing at the bottom of the screen.** Tiling carves the room out instead, so with tiling off, windows will run underneath it. * **The Dock shares that edge.** Move it to a side, or hide it. * **Don't put both bars on the same edge.** `haus.sill.position = "bottom"` plus a second bar is two bars in one place; you'll get a warning on rebuild. ## Turning it off [#turning-it-off] ```nix haus.sill.enable = false; ``` The native menu bar comes back, and the bottom bar goes with it. Tiling and everything else are unaffected. ## Works with [#works-with] Every pill that isn't the bar's own comes from another room, and appears only when that room is on: * **[Windows](/docs/haus/rooms/windows)**: the workspace pills and the leader's letter hints; * **[Focus](/docs/haus/rooms/focus)**: the quiet switch's pill; * **[AI](/docs/haus/rooms/ai)**: the agents readout, amber when a session is waiting on you; * **[Launcher](/docs/haus/rooms/launcher)**: what a click on the logo pill can reach. A missing room removes its pill and nothing else. ## Options [#options] Every setting, with types and defaults: [Bar](/docs/haus/reference/options#bar). # Development (/docs/haus/rooms/development) **hearth** is the terminal room. It sets up the whole command line (emulator, multiplexer, shell, prompt, file manager, editor, and a toolbelt of modern replacements for the usual commands), painted from the same [palette](/docs/haus/rooms/appearance), down to the default editor. | | | | | ----------- | ----------------------------------------------- | ---------------------------------------------------------------------------- | | Terminal | [Ghostty](https://ghostty.org) | title bar hidden, opens straight into the multiplexer | | Multiplexer | [zellij](https://zellij.dev) | custom tab bar, three swap layouts | | Shell | zsh | no framework, fzf-tab, sensible history, git aliases | | Prompt | [starship](https://starship.rs) | | | Files | [yazi](https://yazi-rs.github.io) | markdown, code and image previews | | Editor | [helix](https://helix-editor.com) | the default `$EDITOR`, or neovim, vim, nano ([below](#choosing-your-editor)) | | Jumper | [zoxide](https://github.com/ajeetdsouza/zoxide) | `cd` learns your habits; `cdi` opens a picker | Plus `bat` over `cat`, `lsd` over `ls`, `lazygit` (`lg`), `delta` for git diffs, and `fzf`, `fd`, `jq`, `gh`, `glow`, `fastfetch`, with `nix-index` and `comma` so you can run anything else in nixpkgs without installing it. ## Enable it [#enable-it] ```nix haus.developer.enable = true; haus.developer.languages = [ "node" ]; ``` The terminal stack itself (Ghostty, zellij, zsh, the prompt, the editor) is part of the foundation and always there. `haus.developer.*` is what brings the *tools*: the CLI toolbelt, the Git tooling and the language runtimes, each with a sub-switch of its own (`.toolbelt`, `.git`, `.languages`). ## Ghostty opens into zellij [#ghostty-opens-into-zellij] Launching the terminal drops you into a persistent session called `main`; new panes and tabs inherit your working directory. Ghostty's own `⌘T`, `⌘P`, `⌘Y`, `⌘W` and friends are **unbound** on purpose, so those keys mean the same thing whether or not you're multiplexed. (`⌘C` is not one of them; it stayed with Ghostty, as copy. Day to day you won't press it, since zellij copies a mouse selection the moment you release it, but a `⇧`-drag makes a Ghostty-level selection and `⌘C` takes it.) Two of the unbindings are fixes rather than hand-offs: * **`⌘W`.** Ghostty's default there closes the whole surface, and with close confirmation off a stray `⌘W` silently took the window and the session in it. Now it closes one pane; `⌘⇧W` still closes the window. * **`⌘D` / `⌘⇧D` do nothing**, on purpose. Their Ghostty defaults nest a second terminal *inside* the one zellij is driving: invisible to the multiplexer, so its layout and pane keys would apply to half the window. Use `⌘P`. | Keys | What happens | | ----------------- | ----------------------------------------------------------------------------------------------------------------- | | `⌘P` / `⌘⇧P` | New pane. The plain one hops to the repo's **main checkout** when you're inside an agent worktree; `⇧` stays put. | | `⌘T` / `⌘⇧T` | New tab at `$HOME` / at the focused pane's directory. Fresh tabs are born named `~`, never `Tab #N`. | | `⌘W` | Close the focused pane (and the tab with it, when it was the last one) | | `⌘F` / `⌘⇧F` | [Find](#finding-things) in this pane / across every pane | | `⌘G` | Your GitHub review queue, if you turned it on | | `⌘⏎` | Fullscreen the focused pane, or Ctrl-click the pane body | | `⌘Y` / `⌘⇧Y` | [yazi peek](#yazi-peek), with and without the worktree hop | | `⌘L` | Links picker: every URL in the pane's transcript or scrollback | | `⌘A` / `⌘⇧A` | Spawn a coding agent in its own worktree, beside the focused pane or in place of it | | `⌃⌥⇧A` | Spawn one in **this** checkout instead of its own worktree | | `Ctrl Tab` | Walk tabs in most-recently-used order, browser-style | | `Alt [` / `Alt ]` | Cycle layouts: grid → spiral → columns | **An agent worktree** is the throwaway checkout `⌘A` puts a coding agent in: its own copy of the repo on its own branch, so several agents can work at once without fighting over one set of files. Half the rows above have a `⇧` variant because of it: the plain key assumes you want the *real* repo, and `⇧` says stay in the copy. The [AI room](/docs/haus/rooms/ai) has the rest of the lifecycle: resuming one, setting work aside, cleaning up. **Where that agent's terminal lives is this room's call**, though, and `haus.hearth.lanes.backend` is the switch. `"zellij"`, the default, is what the rows above describe: the lane is a pane in the `main` session. `"zmx"` makes it its own [zmx](https://github.com/neurosnap/zmx) session in its own Ghostty window, tiled by prowl. The session outlives every client attached to it, so `⌘W` detaches and the agent keeps thinking, and reopening the lane rejoins the live conversation instead of replaying a transcript. The trade is splits: zmx has none by design, so prowl tiles real windows rather than a second window manager nested inside a terminal. **zellij boots Locked.** Its single-key submode leaders stay inert until you press `Ctrl g`, so a stray keystroke can't drop you into resize mode. Everything in the table above works regardless. `haus.hearth.zellijStartLocked` flips it. There is **no reload chord**, because there is nothing to reload: zellij watches its own config and applies a change to the running server within a second. A `haus rebuild` lands on the session you already have: same tabs, same panes, same directories, live agent conversations uninterrupted. A plain click on a file path opens it **in your editor, at its line number** (`src/main.rs:42` lands on line 42), and a click on a directory opens a new tab already `cd`'d there. Handy for whatever a build tool just printed. Links work in every pane, including ones running a TUI that has taken the mouse; paths work in any pane no program is tracking the mouse in. ## Finding things [#finding-things] `⌘F` opens a search overlay for the focused pane, `⌘⇧F` the same overlay across every pane. It covers the window but is still a floating pane, so `Esc` puts the tiled layout straight back. Results appear as you type, with the lines around each hit beside them. `⏎` jumps to the pane a hit came from, `^y` copies the line, `^s` flips between this-pane and every-pane without losing your query. **In an agent pane it searches the conversation, not the terminal.** Claude Code and Opencode panes render in the alt-screen, which has no scrollback at all, so searching the grid would only ever find what's on screen this second. Searching the stored conversation finds all of it, including text inside collapsed tool output. Codex panes fall back to scrollback; they report their pane state like the others but carry no conversation id to join to. Every other pane gets its full scrollback. zellij's own in-place search is still there, for when you want matches highlighted in the real pane and `n`/`p` to walk them: `Ctrl g` to unlock, then `s`. Its exits return you to Locked rather than Normal. ## Your review queue [#your-review-queue] `⌘G` opens [gh-dash](https://github.com/dlvhdr/gh-dash) as a borderless overlay, themed like everything else and gone again on `q`. It's off unless you ask: ```nix haus.hearth.ghDash.enable = true; haus.git.org = "your-org"; # or your own account ``` The first line gets you the tabs that are about *you*: issues you opened, ones assigned to you, unread notifications, threads you're in. The second is what a PR tab needs, because a PR tab is a GitHub search and a search needs a scope. Set it and four more appear: **open**, **green** and **red** (the same queue split by whether CI passed; together they *are* the merge decision, and a branch still building shows in neither), and **shipped**. Leave `haus.git.org` empty (right if you read several owners at once) and those four simply aren't written. ## yazi peek [#yazi-peek] `⌘Y` opens a floating file browser with live previews, sized to cover the terminal window that summoned it exactly, so it reads as that window turning into a file browser rather than a popup landing on it. `Enter` on a file pages it fullscreen; `Enter` on a directory opens a new zellij tab there, which makes peek double as a browse-and-pick tab chooser. `q` or `Esc` closes it and leaves your layout untouched. Markdown renders through `glow`, code through `bat`, images through `chafa`. `Y` copies a file's *contents*, not its path. Every floating terminal haus summons wears a thin outline that follows the window's corner curve, so a dark terminal on a dark window still has an edge: ```nix haus.hearth.floatBorder = "accent"; # the default; also "grey", any accent name, or "off" ``` ## Git aliases [#git-aliases] Plain zsh, no framework, and a finite set of shortcuts. The names follow the widely-used Oh-My-Zsh vocabulary where conventions agree, and deliberately skip the ambiguous ones (`gl`, `gr`, `gs`) whose meanings differ between alias sets. `g` itself is `git`. | | | | ----------------- | ----------------------------------------------------------- | | Add | `ga` `gaa` `gapa` | | Branch / checkout | `gb` `gba` `gbd` `gbD` `gbm` `gco` `gcb` `gcl` `gsw` `gswc` | | Commit | `gc` `gca` `gcam` `gcmsg` `gcn` `gcp` `gcpa` `gcpc` | | Diff / log | `gd` `gds` `gdw` `glo` `glog` `gloga` | | Fetch / merge | `gf` `gfa` `gfo` `gm` `gma` `gmc` `gmff` | | Pull / push | `gpl` `gpr` `gp` `gpf` `gpsup` | | Rebase | `grb` `grba` `grbc` `grbi` `grbs` `grt` `grv` | | Status / stash | `gst` `gss` `gsb` `gsta` `gstl` `gstp` `gsts` | | Tag / worktree | `gt` `gwt` `gwta` `gwtl` `gwtr` | Add, replace or remove one per host: ```nix haus.git.shellAliases = { gsync = "git pull --rebase --autostash"; # add gst = "git status --short --branch"; # replace gco = null; # remove }; ``` ## Choosing your editor [#choosing-your-editor] Name one and the room installs it. `haus.hearth.editorName` takes **helix** (the default), **neovim**, **vim** or **nano**: one choice that installs the editor, sets your `$EDITOR`, and points every "open in an editor" action at it. Those open in a new zellij tab, which is why the list is terminal editors. ```nix haus.hearth.editorName = "neovim"; # installed, and $EDITOR haus.hearth.hijackFileAssociations = true; # off by default: make it the # opener for .json, .md, .nix, … ``` helix is the one haus paints from the palette: Nebelung has a theme for it and none for the others, so the alternatives arrive in their own default colours. To point haus at an editor it does **not** install (a GUI one, or something your host installs itself), set `haus.hearth.editor` instead. That one is a shell command haus runs, so it belongs in your host file rather than in a shared desktop, and it wins over whatever `editorName` chose: ```nix haus.hearth.editor = "code -w"; # points at it; installs nothing ``` ## Smaller things you'll notice [#smaller-things-youll-notice] * **Tabs rename themselves.** `cd` into a repo and the tab takes its name; `cd ~` puts it back to `~`. * **Agent-worktree aware.** A new shell opened inside an agent worktree hops to the parent repo, and the tab is named after the real repo rather than the throwaway checkout. `⌘⇧P` is the no-hop escape hatch. * **`Ctrl Tab` really works**, which it usually doesn't on macOS: Ghostty forwards the kitty-protocol encoding so zellij sees it. ## Works with [#works-with] * **[AI](/docs/haus/rooms/ai)**: with both rooms on, agent panes get their zellij bindings and the worktree lifecycle around them. * **[Appearance](/docs/haus/rooms/appearance)**: the whole stack is painted from the same palette. * **[Launcher](/docs/haus/rooms/launcher)**: commands that open a terminal land in the same multiplexer you already live in. ## Remove it [#remove-it] ```nix haus.developer.enable = false; ``` The toolchains, the Git tooling and the toolbelt go; the terminal, shell and editor stay, because they are the floor rather than the room. Take out one piece at a time with `haus.developer.git.enable`, `.toolbelt.enable` or an empty `.languages`. ## Options [#options] Every setting, with types and defaults: [Development](/docs/haus/reference/options#development). # Displays (/docs/haus/rooms/displays) The Displays room is how haus makes the **whole Mac** bigger or smaller, apps it knows nothing about included. `haus.ui.scale` and `haus.fonts` resize haus's own surfaces; display scaling changes what a point means everywhere. It touches nothing until you name a screen. There is no room switch: an empty `haus.displays` is a machine whose displays are left exactly as macOS has them. ## Enable it [#enable-it] ```nix haus.displays.internal.uiScale = "larger-text"; ``` ```sh haus rebuild ``` ## Configure it [#configure-it] Name the screen by what it **is**, not by what it's called today: | Key | Which screen | | ---------- | ---------------------------------------------------------------------------------- | | `internal` | the built-in panel | | `main` | whichever display is currently main | | `` | one specific external monitor; `hausdisp list` prints the UUIDs of what's attached | ```nix haus.displays = { internal.uiScale = "larger-text"; "37D8832A-2D66-02CA-B9F7-8F30A301B230".uiScale = "more-space"; }; ``` `uiScale` takes the same four positions System Settings ▸ Displays offers, named as intents: `more-space`, `default`, `larger-text`, `largest-text`. Each is resolved from the modes *that* panel actually reports, so the same value means the same thing on a 14-inch laptop and a 27-inch monitor rather than the same number of pixels. When two selectors name the same attached panel, the more specific one wins: UUID over `internal` over `main`. That's what lets a host-specific setting refine a broad profile such as `haus.appearance.largePrint` without depending on activation order. An entry for the screen at the office is noted and passed over on the train. A rebuild never fails because you left a monitor behind. ## Works with [#works-with] * **[the Appearance room](/docs/haus/rooms/appearance)**: `haus.appearance.largePrint` sets `displays.main.uiScale` as one of the four levers it pulls, alongside the interface scale and the high-contrast palette. Naming a display yourself wins over it. ## Permissions and side effects [#permissions-and-side-effects] Scaling is applied at each home-manager activation and set **permanently**, so it survives a reboot. Re-applying a mode that's already current is a no-op, so a rebuild doesn't flash your screen. A desktop may set `internal` and `main`; a **UUID is host-only**, because it names one particular piece of hardware. A shared desktop that tried would be refused by name. ## Remove it [#remove-it] Delete the entry and rebuild. The display keeps whatever mode it last had: a removed entry stops *managing* the screen rather than restoring an earlier resolution, so set the position you want explicitly if you want it back. ## Options [#options] Every setting, with types and defaults: [`haus.displays`](/docs/haus/reference/options#displays). # Focus (/docs/haus/rooms/focus) **hush** is the quiet room. One switch (the bell pill in the bar, "Toggle Hush" in the palette, or `hush` in a terminal) turns macOS **Do Not Disturb** on or off, optionally sets your **Slack status** (and silences Slack on your phone), and runs any **hooks** you add. All three surfaces call the same engine, so they can never disagree. Because hush drives the *real* macOS Focus, everything Focus already does comes along free: with **Share Across Devices** on, your iPhone and iPad hush too, and the "allowed to break through" list is whatever you've curated in System Settings. ## Enable it [#enable-it] ```nix haus.hush.enable = true; ``` ## How the toggle works [#how-the-toggle-works] Apple ships no public API or CLI to set a Focus; every "focus CLI" on GitHub is secretly a Shortcuts wrapper. haus takes a different path: macOS has a hidden keyboard shortcut slot (*"Turn Do Not Disturb On/Off,"* symbolic hotkey **175**, disabled by default), and every rebuild binds it declaratively to **⌃⌥⇧⌘ F13**, a chord nothing else uses. `hush` synthesizes that keypress; nothing to author by hand, and the binding heals itself on every rebuild. State is read from the Focus database (`~/Library/DoNotDisturb/DB`) when the calling app has Full Disk Access, so the pill stays truthful even when you toggle Focus from Control Center or your phone; without that grant, hush falls back to remembering its own last toggle. ## The one-time grants [#the-one-time-grants] Synthesizing a keypress needs **Accessibility**; exact state reads need **Full Disk Access**. Both are granted once, per app, in System Settings → Privacy & Security. Run `hush doctor` any time to check each piece and print the fix for whatever's missing. * **Palette toggles work out of the box**: they run under the Pounce daemon, which already holds Accessibility. * **For the bar pill**, add **sketchybar** to Accessibility. * **For the CLI**, add your terminal. * **Optional but recommended**: add the signed **Pounce.app** (or your terminal + sketchybar) to **Full Disk Access** so `hush status` reads the real Focus state instead of guessing. macOS keys TCC grants to a code-signing identity. haus re-signs Pounce with your stable identity precisely so these checkboxes survive rebuilds: tick them once, forget them. ## The Slack leg [#the-slack-leg] macOS Focus already silences Slack banners *on your Mac*. The Slack leg does what Focus can't: tells your **teammates** (status + 🔕) and silences your **phone** (Slack's own DND, all devices). Your previous status is stashed and restored on unhush. It's off by default because it needs a personal Slack token: 1. Create a Slack app for yourself at [api.slack.com/apps](https://api.slack.com/apps) ("From scratch", any name, your workspace). 2. Under **OAuth & Permissions → User Token Scopes**, add `users.profile:write` and `dnd:write`, then **Install to Workspace** and copy the **User OAuth Token** (`xoxp-…`). 3. Stash it in your Keychain; no secret ever touches your config or the Nix store: ```bash security add-generic-password -s hush-slack -a "$USER" -w 'xoxp-…' ``` 4. Point hush at it in your host file: ```nix haus.hush.slack = { enable = true; tokenCommand = "security find-generic-password -s hush-slack -w"; statusText = "heads down"; statusEmoji = ":no_bell:"; snooze = true; # silence Slack push on all devices }; ``` `hush doctor` verifies the token against Slack's API once it's wired. ## Hooks: make hush yours [#hooks-make-hush-yours] Every hush/unhush also runs your own scripts, called with `on` or `off`. Failures are logged, never fatal: ```nix haus.hush.hooks = [ ./onair-light.sh # turn the Elgato red while hushed "/Users/ada/bin/pause-music" # strings run as-is, so $HOME paths work ]; ``` Ideas: pause the music, flip an on-air light, start a Harvest timer, shove chatty apps to a parking workspace with `aerospace`. ## Honest scope [#honest-scope] * hush flips the built-in **Do Not Disturb**, not named Focus modes; those have no symbolic hotkey to bind. * It doesn't manage **which apps or people break through**; that's your Focus allowlist, curated once in System Settings → Focus. * Without Full Disk Access, `hush status` is hush's own memory and can drift if you toggle Focus elsewhere; it self-corrects on the next real toggle either way. ## Troubleshooting [#troubleshooting] **Run `hush doctor` first**: it checks the hotkey binding, the state-read grant, and the Slack token, and prints the fix for each. * **Click the pill, nothing happens**: sketchybar has no Accessibility grant yet; toggle from the palette instead, which needs nothing. * **Pill shows the wrong state after toggling from Control Center**: grant Full Disk Access for exact reads. * **The chord stopped working**: a rebuild re-binds hotkey 175 on every activation and heals it. ## Works with [#works-with] * **[Bar](/docs/haus/rooms/bar)**: the pill that shows the state and toggles it. * **[Launcher](/docs/haus/rooms/launcher)**: the same toggle as a command. Neither is required: with both rooms off, `hush` is still a CLI. ## Remove it [#remove-it] ```nix haus.hush.enable = false; ``` The pill, the command and the CLI go. The Do Not Disturb chord this room adds is one macOS ships disabled, so switching the room off leaves a spare binding rather than taking one away, the opposite of the launcher's ⌘Space. `haus.hush.slack.tokenCommand` is host-only: it names a secret on one machine. ## Options [#options] Every setting, with types and defaults: [Focus](/docs/haus/reference/options#focus). # Launcher (/docs/haus/rooms/launcher) The Launcher room installs **[pounce](/docs/pounce)**, a native command palette where every command is a shell script in a folder. Hit **⌘Space**, fuzzy-type, hit **Return**: apps, your own commands, clipboard history, an emoji grid and inline answers, all in one list. Pounce is free, MIT and installs standalone from Homebrew without any of this. What the room adds is the wiring: the daemon as a login item, ⌘Space actually free, and the one macOS permission surviving a rebuild. ## Enable it [#enable-it] ```nix haus.pounce.enable = true; haus.keys.palette = "cmd-space"; # or alt-space, ctrl-space, "none" ``` ```sh haus rebuild ``` Only `cmd-space` displaces Spotlight; moving the palette elsewhere is how you keep both. haus frees ⌘Space from Spotlight so pounce can own it, which is the step a standalone install has to do by hand. ## Configure it [#configure-it] `config.json` is **generated** from `haus.pounce.*`, so `pounce config init` refuses inside haus; the next rebuild would put the generated file straight back. Change it in your host file instead, and the rebuild bounces the daemon for you when one of the startup-only keys moves. ```nix haus.pounce.windowSwitcher = true; # ⌘Tab by window; defaults on here haus.pounce.autoQuit.enable = false; # quit an app when its last window closes haus.pounce.items."mode:emoji".hotkey = null; # leave Fn/Globe native ``` `haus.ui.scale` writes pounce's `scale` for you, and your `haus.theme.flavor` / `haus.theme.contrast` reach it as a palette file, so the palette matches the rest of the house without a second theme setting. ```nix haus.pounce.signingIdentity = "Apple Development: you@example.com (XXXXXXXXXX)"; ``` A plain Nix rebuild is ad-hoc signed, and macOS ties an Accessibility grant to a signature, so without this you re-approve the dialog after every rebuild. Set it and the room re-signs a stable copy of the app with your identity, and the grant persists. It is **host-only**: it names a certificate on one machine, so a shared desktop may not set it. ## Works with [#works-with] * **[Apps](/docs/haus/rooms/apps)**: **Install App** writes a roster entry and rebuilds. * **[AI](/docs/haus/rooms/ai)**: **Spawn Agent** creates a named worktree and opens a session in it. * **[Focus](/docs/haus/rooms/focus)**: the quiet switch is a command here too. * **[Windows](/docs/haus/rooms/windows)**: the opt-in `⌘Tab` window switcher, which groups its rows by AeroSpace workspace. Each command appears only when its room is on. ## Remove it [#remove-it] ```nix haus.pounce.enable = false; ``` haus disables Spotlight's shortcut with a one-way write, and nothing re-enables it. Put it back yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts. ## Options [#options] Every setting, with types and defaults: [Launcher](/docs/haus/reference/options#launcher). # Security (/docs/haus/rooms/security) **collar** is the small auth room: it turns on **Touch ID for `sudo`**, and (the part stock Touch-ID-for-sudo gets wrong) makes that work **inside a terminal multiplexer** like zellij or tmux, where it would otherwise silently fail. ## Enable it [#enable-it] ```nix haus.collar.enable = true; haus.collar.passwordlessRebuild = true; ``` ## What it turns on [#what-it-turns-on] ```nix security.pam.services.sudo_local.touchIdAuth = true; # fingerprint for sudo security.pam.services.sudo_local.reattach = true; # make it work in tmux/zellij ``` `reattach` pulls in [`pam_reattach`](https://github.com/fabianishere/pam_reattach), which reattaches the sudo process to your GUI login session. Without it, `sudo` inside a multiplexer detaches from Aqua and the Touch ID prompt just beachballs. Cancel the prompt and it falls back to a typed password. collar also installs `/etc/sudoers.d/darwin-rebuild`, exempting activation from authentication entirely: ``` ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/darwin-rebuild ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/haus-activate ``` Activating a build is the one privileged thing this desktop does constantly, and a prompt per rebuild buys nothing: you already authenticated to *build* it. `darwin-rebuild` is the classic route (and the only one for `haus rollback`); `haus-activate` is the faster path `haus rebuild` and `bench try switch` take, building as you and handing the result to root without a second evaluation. Neither is a wider grant than `darwin-rebuild switch --flake ` already was; `haus.collar.passwordlessRebuild = false` drops both. macOS 26.6 shipped sudo 1.9.17, which stopped dereferencing a command's symlink before matching sudoers: a rule against a `/nix/store/…` path only matches if you type that literal path, which nobody does. Matching `/run/current-system/sw/bin/darwin-rebuild` is what survives a rebuild. Type `sudo -v` in a zellij pane and the Touch ID dialog floats up right over the multiplexer. Touch the sensor, no password typed. (`darwin-rebuild` is the happy exception; it doesn't even ask.) ## What it does *not* do [#what-it-does-not-do] collar stays out of key management. **YubiKey and GPG** live outside Nix entirely: key material, smartcard setup, and `pinentry` are handled by `gpg-agent` + `pinentry-mac` at runtime, keeping secrets off the Nix store. Commit signing itself is wired up in [the Development room](/docs/haus/rooms/development) from `haus.git.signingKey`; collar only handles `sudo` authentication. ## Two switches [#two-switches] ```nix haus.collar.enable = true; # Touch ID for sudo, reattach and all haus.collar.passwordlessRebuild = true; # activation never authenticates ``` Both default on. Turn `passwordlessRebuild` off on a machine you don't administer alone, and activation goes back to asking. Turn `enable` off and macOS's stock password prompt comes back everywhere. ## The rest of the room [#the-rest-of-the-room] Three more surfaces, each unset by default so a fresh Mac keeps macOS's own answer until you say otherwise: ```nix haus.lock.requirePassword = true; # and .requirePasswordDelay haus.security.firewall.enable = true; # the built-in application firewall haus.secrets.provider = "keychain"; # where secret values come from ``` The firewall is off on a fresh Mac; it is the setting to turn on for a laptop that joins networks you don't own. Secrets themselves never live in your config; haus declares which exist and fetches them at runtime. ## Works with [#works-with] * **[Development](/docs/haus/rooms/development)**: commit signing is configured there; this room is what stops `sudo` asking for a password. * **[AI](/docs/haus/rooms/ai)**: `passwordlessRebuild` is what lets an agent run `haus rebuild` end to end without a prompt it can't answer. ## Remove it [#remove-it] ```nix haus.collar.enable = false; ``` `sudo` goes back to the password prompt. The firewall and lock settings stop being written but keep whatever value they last had: unsetting an option stops managing a macOS setting rather than restoring the one before it. `haus.secrets.provider` is host-only: where a machine keeps its secrets is a fact about that machine. ## Options [#options] Every setting, with types and defaults: [Security](/docs/haus/reference/options#security). # Shelf (/docs/haus/rooms/shelf) You know the dance: drag a file, realise the window you wanted is buried under four others, let go, dig it out, drag again. The Shelf room installs **[perch](/docs/perch)**, which ends it: start dragging anything, flick up to the notch, and a small shelf drops down to catch it. Pile in as much as you like, from as many places as you like, then grab any tile and carry the whole group to its real home in one motion. This page is the **room**: the wiring, the one option, and what a rebuild does. The app itself is documented in [its own tree](/docs/perch). ## Enable it [#enable-it] ```nix haus.perch.enable = true; ``` ```sh haus rebuild ``` Perch is copied to `/Applications` and kept current with the rest of the house. Standalone and in-the-house are the same app: if you already installed it with `brew install --cask hausfold/tap/perch`, letting the room own it is the upgrade. ## Configure it [#configure-it] There is one setting, and it's about colour: ```nix haus.perch.followSystemAppearance = true; ``` With it on, the shelf follows macOS Light/Dark itself (no rebuild, no relaunch) at your `haus.theme.contrast`, rather than being pinned to `haus.theme.flavor` like every other themed tool. Perch has no theme picker of its own (a five-second surface has nowhere to put one), so this is the only word on its colours. ## Works with [#works-with] * **[the Appearance room](/docs/haus/rooms/appearance)**: the shelf takes your palette and contrast, unless you hand the polarity to macOS with `followSystemAppearance`. ## Permissions and side effects [#permissions-and-side-effects] This room grants **nothing**, because perch asks for nothing: no Accessibility, no Input Monitoring, no Screen Recording, no Dock icon, no telemetry. There is no permission step in a rebuild for it, which is unusual enough here to be worth saying. One macOS setting fights it, and haus can't fix it for you: **System Settings ▸ Desktop & Dock ▸ "Drag windows to top of screen to enter Mission Control"**. macOS arms that top-edge trigger for the whole of any drag, files included, and it fires over the same band the shelf lives in. Leave it on and the Dock steals the drop. [Install](/docs/perch/install) has it too, for a Mac without haus. ## Remove it [#remove-it] ```nix haus.perch.enable = false; ``` The room stops installing and updating it. The copy already at `/Applications/Perch.app` stays until you delete it; a rebuild never removes an app from your Applications folder behind your back. ## Options [#options] Every setting, with types and defaults: [`haus.perch`](/docs/haus/reference/options#shelf). # Text expansion (/docs/haus/rooms/text-expansion) Type `@@` and get your email address. Type `;addr` and get your postal address. The Text expansion room wires up [espanso](https://espanso.org) and keeps the table of snippets in the same file as the rest of your machine. It works where macOS's own text replacement doesn't (terminals and many Electron apps), because espanso injects keystrokes rather than relying on the system text engine. ## Enable it [#enable-it] ```nix haus.snippets.enable = true; ``` ```sh haus rebuild ``` ## Configure it [#configure-it] The table is a list of `trigger` → `replace` pairs: ```nix haus.snippets = { enable = true; matches = [ { trigger = "@@"; replace = "ada@example.com"; } { trigger = ";sig"; replace = "— Ada"; } { trigger = ";shrug"; replace = "¯\\_(ツ)_/¯"; } ]; }; ``` They're written to `~/.config/espanso/match/default.yml`. Only the plain trigger→replace form is exposed here; for **dynamic** matches (dates, shell output, forms), drop a hand-written `.yml` beside it in `~/.config/espanso/match/`. espanso loads every file in that directory, so the two live together happily. `matches` is one value, not an accumulator: naming it in your host discards whatever the desktop set rather than appending to it. If you want the desktop's snippets plus your own, restate both, or put yours in a hand-written `.yml` beside the generated one, where nothing overwrites anything. ## Works with [#works-with] Nothing else in the house. Expansion happens in whichever app has focus, so the room has no bar pill, no palette command and no keybinding of its own. ## Permissions and side effects [#permissions-and-side-effects] espanso needs a one-time macOS **Accessibility** grant: System Settings ▸ Privacy & Security ▸ Accessibility ▸ enable Espanso. Until you give it, nothing expands. haus installs the **signed Espanso.app cask** and runs that, rather than a nix-store binary, on purpose: the grant is keyed to a stable identity, so it survives reboots and nixpkgs bumps. You grant it once, not on every rebuild, and espanso's own "something is wrong" window stops appearing at login, since that window only ever meant the grant went missing. ## Remove it [#remove-it] ```nix haus.snippets.enable = false; ``` The cask is no longer declared and the generated match file stops being written. Revoke the Accessibility grant yourself if you want it gone, and remove any hand-written `.yml` files you added; haus never wrote those, so it doesn't take them away. ## Options [#options] Every setting, with types and defaults: [`haus.snippets`](/docs/haus/reference/options#text-expansion). # Windows (/docs/haus/rooms/windows) **prowl** is the tiling room. Windows arrange themselves into a tree instead of piling up, and the keyboard moves them around it: launch, focus, resize, throw one to another workspace, without reaching for the mouse. It feels like a tiling Linux rig; underneath it is still native macOS windows, driven by [AeroSpace](https://github.com/nikitabobko/AeroSpace). Two keys carry almost all of it: * **⇪ Caps-Lock is the leader.** Tap it and the bar turns into letter hints; press an app's letter to launch or focus it. It is also the door to moving focus, resizing, and jumping workspaces. * **⌥ Option is the everyday modifier**, for layouts and workspace moves. Focus-by-arrow deliberately sits *behind* the leader rather than on a bare `⌥` chord: `⌥`, `⌘` and `⌃` with the arrow keys are all macOS text navigation, and a window manager that took them would break typing everywhere. `⌥H/J/K/L` is there too, for hands that want Vim keys. ## Enable it [#enable-it] ```nix haus.prowl.enable = true; haus.keys.leader = "caps"; # or "none" to keep Caps Lock as Caps Lock haus.keys.windowNav = "alt"; ``` ## Tap ⇪, then [#tap--then] | Key | What happens | | ---------------------- | ------------------------------------------------------------------------------ | | `` | Launch or focus that app: `T` terminal, `B` browser, and whatever else you add | | `1`–`4` | Go to workspace 1–4 | | `⇧` + any of the above | **Throw** the focused window there and follow it | | `←↓↑→` | Move focus, and stay in navigate mode so arrows keep working | | `-` / `=` | Shrink / grow, repeatably | | `V` / `E` | Clipboard history / emoji picker, via pounce | | `Z` | Reopen the last closed app | | `,` | macOS System Settings: the `⌘,` convention, one level up | | `` ` `` | Re-sort every window to its workspace | | `/` | The cheatsheet | | `Esc` | Out | The `⇧` row is the one worth learning: going somewhere and taking the window with you is the same motion one `⇧` apart. To send a window away and *stay*, throw it and `⌘Tab` back. ## Without the leader [#without-the-leader] | Keys | What happens | | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `⌥/` | Tiles layout; toggles the split horizontal ↔ vertical | | `⌥,` | Accordion layout | | `⌥F` | Fullscreen | | `⌥⇧Tab` | Move this workspace to the next monitor | | `⌥⇧;` | **Service mode**: `R` flattens the tree, `F` floats a window, `Backspace` closes every other window, `⌥⇧H/J/K/L` joins a neighbour | `⌥Tab` is deliberately unbound: [pounce](/docs/pounce/beyond-launching#a-window-switcher-not-an-app-switcher)'s `⌘Tab` answers the same question better, with a most-recently-used list of *windows* that goes as far back as you like and gathers its rows by workspace. macOS scrambles window positions when the machine wakes. prowl watches for that and re-sorts them to their workspaces on its own; then \` does it on demand. ## Not fond of these keys? [#not-fond-of-these-keys] None of it is baked in. Three options move the whole vocabulary at once: ```nix haus.keys.leader = "caps"; # caps | alt-space | none haus.keys.windowNav = "alt"; # alt | ctrl-alt | cmd-alt | none haus.keys.palette = "cmd-space"; # cmd-space | alt-space | ctrl-space | none ``` The three reasons people change them: * **Keep Caps-Lock as Caps-Lock.** `leader = "alt-space"` gives you the launcher without remapping the key. * **A non-US layout.** On many layouts `⌥`+letter is how you type an accented character, so a window manager owning `⌥` makes the keyboard unusable. `windowNav = "ctrl-alt"` moves every chord in one go, and the cheatsheet moves with it, because both are generated from the same table. * **Mouse-first.** `leader = "none"` with `windowNav = "none"` leaves the keyboard entirely alone: prowl still tiles, but claims no chords. `palette = "none"` stops haus taking ⌘Space from Spotlight; it's the only value that takes it. On a machine that already ran with it, re-enable Spotlight's shortcut yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts; nothing in haus gives it back. Whatever you pick, the in-system cheatsheet (tap the leader, then `/`) describes the keys you actually have, not the defaults above. ## Workspaces, and what lives on them [#workspaces-and-what-lives-on-them] A workspace is a named place with apps that belong to it. Declare one, and its apps' windows move there by themselves: ```nix haus.workspaces.comms = { key = "c"; # ⇪ ⇧C throws the focused window here icon = ":slack:"; # the bar pill's glyph apps = [ "slack" "discord" ]; # roster ids }; ``` The attribute name **is** the workspace id: a letter like `T`, or a word like `comms`. Three fields: * **`apps`**: [roster](/docs/haus/rooms/apps) ids that live here. Launching any of them from the leader lands you here. An app belongs to at most one workspace, and naming an id that isn't in the roster warns rather than failing quietly. * **`key`**: the `⇪ ⇧`-throw key. There is no bare-key binding for a workspace: that namespace belongs to the launcher letters, one of which usually doubles as "open something here". `null` means the workspace is reachable only by launching an app that belongs to it. * **`icon`**: a [SketchyBar app-font](https://github.com/kvndrsslr/sketchybar-app-font) ligature like `:slack:` for the bar's pill; any other string is drawn in the bar's own font. Workspaces `1`–`4` always exist, whatever any app claims. The launcher letters themselves come from `haus.roster`, the one list that also says where each app is installed from; that's [the Apps room](/docs/haus/rooms/apps). Out of the box the only letters taken are `T` for the terminal and `B` for the browser. ## Turning it off [#turning-it-off] ```nix haus.prowl.enable = false; ``` The Caps-Lock remap, the launcher and AeroSpace all stay out of the way. Nothing else in haus depends on it; [Leaving](/docs/haus/leaving#switch-off-a-room) does the same for the other rooms. ## Works with [#works-with] * **[Bar](/docs/haus/rooms/bar)**: the workspace pills, and the letter hints the leader draws, are this room's contribution to the bar. No bar, no hints; the tiling itself is unaffected. * **[Apps](/docs/haus/rooms/apps)**: a roster entry's `key` is what the leader launches, and its workspace is where its windows land. * **[Launcher](/docs/haus/rooms/launcher)**: its opt-in `⌘Tab` window switcher answers the same question from the other side. ## Options [#options] Every setting, with types and defaults: [Windows](/docs/haus/reference/options#windows). # What perch is (/docs/perch) You know the dance: drag a file, realise the window you wanted is buried under four others, let go, dig it out, drag again. **perch** ends it. Start dragging **anything**, flick up to the notch, and a small shelf drops down to catch it. Pile in as much as you like, from as many places as you like. Then grab any tile and carry the whole group to its real home in one motion. It has no Dock icon and asks for no permissions. It sees what you drop on it, and nothing else. These pages are the manual. [The perch page](/perch) is the short version: one screen, the dance, and the install command. ## What it holds [#what-it-holds] Files, folders, Photos exports, images dragged out of Safari, links and plain text all land in the same shelf. Take one tile or grab the stack and take the lot; whatever the destination accepts leaves the shelf, and a refused drop springs back. **It hands out copies.** perch stages its own copy of whatever you drop and hands out copies of that. It never moves, renames, edits or deletes the thing you dragged in, so an interrupted drag cannot lose data. A tile only appears once its copy has finished, so nothing on the shelf is half-written and quitting doesn't lose it. ## What it doesn't do [#what-it-doesnt-do] * **No permissions.** No Accessibility, no Input Monitoring, no Screen Recording, no Dock icon. * **No telemetry.** Nothing about your files is ever written to a log. * **Nearly offline.** The only thing perch sends to the internet is an hourly look at its own release tag, and Settings turns that off. It also listens on your own network for an iPhone you paired yourself, encrypted end to end and off in Settings if you don't want it. ## Questions [#questions] **Where do my files live?** Where they already were. perch keeps its copies inside its own sandbox container, and it only ever knows about the things you dragged onto it. The [privacy policy](/perch/privacy) is the whole story, and it's shorter than this page. **What licence?** Every line of perch is public and readable on GitHub, and each release becomes Apache-2.0 two years after it ships. Until then you may read it, change it and build it for yourself; what you may not do is turn it into a competing product or hand out your own builds of it. That's [fair source](https://fsl.software), and it's the honest version of "mostly open". **Another shelf app?** There are others, and they're good. perch's angle is dependability and restraint: copies rather than moves, no permissions to grant, no telemetry, and a shelf that lives where your hand already is. ## Inside a haus machine [#inside-a-haus-machine] perch runs perfectly well on its own, and that is how most people meet it. It is also a **room** in [haus](/docs/haus), the macOS layer, where it is installed and kept current with the rest of the machine, and where it can hand its light/dark polarity to macOS. Standalone and in-the-house are the same app; only the wiring differs. See [the Shelf room](/docs/haus/rooms/shelf). # Install (/docs/perch/install) ```sh brew install --cask hausfold/tap/perch ``` That's the whole install. No account, no sign-in, no licence key. Prefer a download? The [latest release](https://hausfold.co/download/perch) is the same build, signed with our Apple Developer ID and notarized, so it opens straight away with no Gatekeeper prompt and no quarantine hack. ## Turn one macOS setting off [#turn-one-macos-setting-off] In **System Settings ▸ Desktop & Dock**, turn *off* **"Drag windows to top of screen to enter Mission Control"**. macOS arms that top-edge trigger for the whole of any drag, files included, and it fires over the same band the shelf lives in. Leave it on and the Dock steals the drop. It's the only thing perch asks of your Mac. ## What it needs [#what-it-needs] * **macOS 14** or newer. * **A notch is nice, not required.** The shelf works on any Mac and on any display. * **Nothing else.** perch asks for none of the permissions that make a Mac app feel invasive: no Accessibility, no Input Monitoring, no Screen Recording. ## On a haus machine [#on-a-haus-machine] If your Mac runs [haus](/docs/haus), let the Shelf room own perch instead; it installs it and keeps it current with everything else, and survives a rebuild: ```nix haus.perch.enable = true; ``` ```sh haus rebuild ``` If you already installed it with Homebrew, letting the room own it is the upgrade. [The Shelf room](/docs/haus/rooms/shelf) has the one option it adds. ## Updating [#updating] perch checks its own release tag hourly and tells you when there's a newer one; applying it is always your keystroke, and Settings turns the check off altogether. On a haus machine, `haus update` carries it with everything else. # Using the shelf (/docs/perch/using) The shelf has no window to open and no icon to click. It appears when you're already dragging something and disappears when you're not. ## Catching a drag [#catching-a-drag] Start dragging anything, then flick up toward the notch. The shelf drops down and catches whatever you let go of over it. You can do that as many times as you like, from as many places as you like (Finder, Safari, Photos, Mail, a Save dialog), and it all piles onto the same shelf. Files, folders, images dragged out of a web page, links and plain text all land the same way. ## Carrying it out [#carrying-it-out] Drag a tile off the shelf and drop it where it belongs. Grab the **stack** and you take everything at once. Whatever the destination accepts leaves the shelf; a refused drop springs back rather than vanishing. Because perch hands out copies of its own staged copy, dropping the same pile into three places is three drags, not three trips back to where the files came from. Nothing on the shelf is ever half-written, which is also why a big folder takes a moment to show up, and why quitting mid-copy doesn't lose it. ## What stays, and for how long [#what-stays-and-for-how-long] Nothing leaves the shelf on its own. **Clear asks first**, and the expiry timer is **off** unless you turn it on in Settings, so a pile you left there yesterday is still a pile today. Your originals are untouched throughout: perch stages a copy inside its own sandbox container and never moves, renames, edits or deletes the thing you dragged in. ## Settings worth knowing [#settings-worth-knowing] | | | | ----------------- | ------------------------------------------------------------------------------------------------------------------------------- | | **Expiry** | Off by default. Turn it on to have the shelf empty itself after a set time | | **Release check** | An hourly look at perch's own release tag, and the only thing it sends anywhere. Turn it off and perch never opens a connection | | **iPhone** | perch listens on your own network for an iPhone you paired yourself, encrypted end to end. Off if you don't want it | | **Appearance** | Follows your Mac's Light/Dark, or a fixed polarity. On a haus machine, `haus.perch.followSystemAppearance` is the same switch | # What pounce is (/docs/pounce) **Pounce** is a free, open-source, native command palette for macOS: a fast, dmenu-style picker you summon with a hotkey. Hit **⌘Space**, fuzzy-type, hit **Return**. It launches apps, runs commands, answers quick questions inline, and hosts a handful of built-in modes: clipboard history, an emoji and symbols grid, screenshots, and more. Every launcher lets you open an app. The moment you want it to do something of your own, you are reading an extension SDK, making an account, or waiting for someone else to publish the thing you needed twenty minutes ago. Pounce takes the other road. Its whole personality is one idea: **every command is a file.** Drop a shell script in a folder and it's in the palette: five lines, two comments, no plugin API, no extension store, no account. It is [MIT licensed](https://github.com/hausfold/pounce/blob/main/LICENSE) (every feature in these pages, no paid tier), and it installs standalone from Homebrew with **no Nix required**. ## What's in it [#whats-in-it] | | | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | **Apps & actions** | One list, fuzzy-matched. Type `saf`, get Safari; your own commands sit in the same list, ranked the same way. | | **Quick answers** | `2*847`, `72 f in c`, `100 usd in eur`, `14:00 utc in pst`: the answer pins to the top row and `⏎` copies it. | | **Clipboard history** | Your recent copies in a two-pane view, searchable, with an optional paste straight back into the app you came from. | | **Find Files** | Search by name as you type, over Spotlight's own index. `⏎` opens, `⌘⏎` reveals in Finder, `⌥⏎` copies the path. | | **Emoji & symbols** | One grid for both: type `command` and get ⌘, `arrow` and get →. Plain Unicode, so it pastes anywhere. | | **Settings panes** | Every System Settings pane by name: "Displays" goes to Displays, not to the Settings window. | | **Windows** | An opt-in `⌘Tab` that walks your actual *windows*, newest first, with type-to-filter. | | **Plugins** | Docker, SSH hosts, Tailscale, Spotify, Bluetooth, audio devices, GitHub, caffeinate. Off by default, on by id, each still just a script. | A screenshot browser, a live camera peek and a searchable cheatsheet live beside them. ## How it behaves [#how-it-behaves] * **Instant.** The daemon holds the hotkey in-process, so a press lands in an already-warm program: no shell, no client to spawn, no round trip. * **It learns.** Match quality, then frequency × recency on a 72-hour half-life, plus a nudge for apps you've just installed. What you meant is usually the first row. * **Quiet.** No account, no sign-in, no telemetry, nothing to log in to and nothing reported back. * **Offline.** Two outbound calls exist and both are optional: daily reference rates for currency conversion, and an hourly look at pounce's own release tag. Either can be turned off in [config](/docs/pounce/config); everything else works with the Wi-Fi off. * **Updates are nudged, never automatic.** A pending release pins itself to the first row and notifies at most once a day; `⌘⏎` skips that version. Applying it is always your keystroke. * **Diagnosable.** `pounce doctor` answers the three questions that go wrong: is the daemon up, is Accessibility granted, is something else eating your hotkey. ## Questions [#questions] **Free how?** Free as in no money and free as in MIT. Read it, fork it, ship your own; there is no licence key, no trial timer and no tier above the one you have. **Another launcher?** There are good ones, and if you want a marketplace of ready-made extensions you should use one of them. Pounce's angle is ownership: the extension API is the shell you already know, and nothing you write depends on us staying interested. **My scripts?** They stay yours, as files, in your own folder. Nothing is imported into a database, so backing them up is backing up a directory and moving Macs is copying one. ## Inside a haus machine [#inside-a-haus-machine] Pounce runs perfectly well on its own, and that is how most people meet it. It is also a **room** in [haus](/docs/haus), the macOS layer, where the daemon, the hotkey and the Accessibility grant are wired up with the rest of the machine and survive a rebuild. Standalone and in-the-house are the same app; only the wiring differs. See [the Launcher room](/docs/haus/rooms/launcher). # Beyond launching (/docs/pounce/beyond-launching) Both of these are **off by default**, and both need the [Accessibility grant](/docs/pounce/install#grant-accessibility). They share one grant and one set of observers, so turning both on costs one approval, not two. ## A window switcher, not an app switcher [#a-window-switcher-not-an-app-switcher] Pounce ships an **MRU window switcher**: tap the chord to bounce to your last window even on another workspace, hold it and keep tapping to walk older ones, or type while holding to fuzzy-filter. It moves by *window*, so two Ghostty windows are two rows. ```jsonc { "windows": { "enabled": true, "key": "tab", "modifiers": ["cmd"] } } ``` Inside haus that is `haus.pounce.windowSwitcher`, where it defaults **on**. **With a tiling window manager**, rows gather by workspace under a header each, and focusing goes through `aerospace focus --window-id` so a window parked elsewhere surfaces properly. One deliberate wrinkle: a bare tap-and-release skips the workspace you're on and lands on the most recent window on a **different** one: with two windows tiled side by side, the most recent is one you're already looking at, so landing there wouldn't be a switch. The skipped siblings are still the rows right below you. The `windows` block is captured when the daemon arms, so an edit needs a restart, not just a re-open: ```sh launchctl kickstart -k "gui/$(id -u)/com.hausfold.pounce" ``` ## Quit an app when its last window closes [#quit-an-app-when-its-last-window-closes] macOS keeps an app running after you close its last window. If your muscle memory is from Windows, that's a ⌘Q you forgot every time. ```jsonc { "autoQuit": { "enabled": true, "delay": 2, "exclude": ["com.apple.finder"] } } ``` In haus: `haus.pounce.autoQuit.enable`, off by default. **Asked, not killed**: the same Quit event ⌘Q sends, so unsaved work puts up its sheet and stays open. **The delay is load-bearing.** `delay` (2s, clamped 0.25–3600) is what tells "I'm done with this app" apart from "close this window, open another," which is what a browser does on ⌘N. **`exclude` replaces the default, it doesn't extend it.** Out of the box it is `["com.apple.finder"]`, since Finder is the one app macOS runs windowless by design. Name anything else and you have dropped Finder from the list. An app doing work in the background is still just an app with a window. Closing the Docker Desktop dashboard asks Docker to quit, which stops your containers. The same shape catches media players, torrent clients, and any chat app you keep open for notifications. That's what `exclude` is for. The whole `autoQuit` block is startup-only too, so **any** edit inside it needs the daemon restart above, not just flipping `enabled`. # CLI reference (/docs/pounce/cli) ```sh pounce --launcher # apps + commands palette (the default mode) pounce --max-empty 7 # rows to show before you type pounce -p "Pick:" # generic picker; reads lines from stdin pounce -i "sf.symbol.name" # icon for the picker pounce -p "Search:" --chain # picker whose free-text Enter feeds another pounce step pounce run cmd:emoji # run one item by its key (for external binders) # built-in windows (items, not flags) pounce run mode:clipboard # clipboard history pounce run mode:emoji # emoji + symbols picker pounce run mode:screenshots # screenshot browser pounce run mode:camera # live camera preview pounce run mode:filesearch # file/folder search (Spotlight index) pounce run app:/Applications/Ghostty.app pounce --cheatsheet [path] # cheatsheet overlay pounce --transform 'tr a-z A-Z' # rewrite the selected text through a shell filter # settings pounce config print # print the annotated config, touching nothing pounce config init # write ~/.config/pounce/config.json # housekeeping pounce doctor # diagnose a dead/slow hotkey or binding pounce --request-accessibility / --check-accessibility pounce --request-bluetooth / --check-bluetooth pounce --help / --version ``` ## Flags [#flags] | Flag | Purpose | | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `-p`, `--placeholder` | Prompt text for the search field | | `-i`, `--icon` | SF Symbol icon for the picker | | `--chain [keys]` | Mark a free-text commit as feeding another `pounce` step. Optional comma-separated action list (`--chain enter,opt`); bare `--chain` means `enter` | | `--actions ` | Label the action bar on a rowless step: `"Go\|shift:New line\|cmd:Screenshot\|opt:Drafts"` | | `--draft ` | Keep typed text on any non-commit dismissal, filed under `` | | `--query ` | Open with the box pre-filled, caret at the end | | `--transform ` | Pipe the current selection through a shell filter and paste the result back (needs Accessibility) | | `--copy-file ` | Copy a file's contents to the clipboard | | `--max-empty ` | How many rows to show before anything is typed | | `--cheatsheet [path]` | Open the cheatsheet overlay | ## Subcommands [#subcommands] | Subcommand | Purpose | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- | | `run ` | Run one item by the key `items` uses, for binders that own the keystroke | | `config print` / `config init` | Print the annotated config, or write one | | `doctor` | Report the daemon, the grant, and every binding actually armed | | `focus ` | Focus/DND: `focus status\|toggle\|on\|off`, forwarded to the daemon | | `drafts ` | `save` (stdin) / `list` / `get ` / `rm ` / `clear`. Files live in `~/.local/state/pounce/drafts/.tsv`, newest first, capped at 20 | ## Homebrew binaries [#homebrew-binaries] `brew install pounce` puts `pounce` (the app/daemon), `pounce-palette` (the launcher wrapper, for binding a hotkey externally), and one `pounce-` wrapper per built-in on your `PATH`. Inside haus, set the palette up via `haus.pounce` instead; the module handles the daemon, the hotkey, and permission survival for you. # Commands (/docs/pounce/commands) A command is a shell script, and everything in this page is one, including every built-in. There is no privileged tier: read any of them, copy one, or replace it with your own. ## Built-in commands [#built-in-commands] Out of the box: **Activity Monitor**, **Brew Services**, **Camera**, **Capitalize** / **Lowercase** (rewrite the text selected in *any* app), **Clipboard History**, **Emoji & Symbols**, **Find Files**, **Force Quit**, **Lock Screen**, **Ports**, **Report Pounce Issue**, **Screenshots**, and **System Settings**, where every pane is its own command, so "Displays" goes to Displays rather than to the Settings window. On a Homebrew install there is one more: **Update Pounce**, which self-updates in place. `brew upgrade pounce` on Homebrew, an in-place swap when the app was dragged to `/Applications`, or a nudge to `haus update` on Nix and haus, since the next rebuild would revert an in-place update anyway. ## Two-step commands [#two-step-commands] Some commands open a second picker instead of acting immediately (**Brew Services** lists services, **Force Quit** lists processes), and Pounce swaps the list **in place**, with no window flicker. That is not a special capability; it's a script calling `pounce` again. See [writing your own command](/docs/pounce/writing-commands#submenus-and-chaining). ## The plugin shelf [#the-plugin-shelf] A shelf of plugins ships **off by default**, because each assumes a specific tool, service, or app: | Plugin | What it does | Needs | | ----------------- | ----------------------------------------------- | ----------------------------------- | | **Audio Devices** | Switch sound output & input | `switchaudio-osx` | | **Bluetooth** | Connect & disconnect paired devices | `blueutil` | | **Caffeinate** | Keep the Mac awake, indefinitely or on a timer | nothing | | **Docker** | Start/stop/restart & tail container logs | any engine that answers `docker ps` | | **GitHub** | Jump to your PRs, reviews, issues & repos | `gh`, authenticated | | **Perplexity** | A fresh Perplexity thread from a typed question | a browser | | **Spotify** | Playback controls | Spotify.app | | **SSH Hosts** | Pick a host from `~/.ssh/config` | hosts in `~/.ssh/config` | | **Tailscale** | Connect/disconnect, copy an IP | the `tailscale` CLI | On Nix, enable by id: ```nix pounce-commands.override { plugins = [ "docker" "ssh" ]; } ``` Off Nix, every plugin is still just one script: copy it from [`pkgs/pounce-commands/optional/`](https://github.com/hausfold/pounce/tree/main/pkgs/pounce-commands/optional) into `~/.config/pounce/commands/` and it is live on the next ⌘Space. ## Where commands are discovered [#where-commands-are-discovered] Later sources **shadow** earlier ones by filename, so yours always win: 1. `$POUNCE_BUILTIN_DIR`: the built-ins that ship with the package 2. `$POUNCE_EXTRA_COMMAND_DIRS` (colon-separated): layers added by Nix consumers like haus 3. `$POUNCE_COMMAND_PATH` (colon-separated): ad-hoc extra directories 4. `~/.config/pounce/commands`: **yours**, highest precedence Want to change what "Lock Screen" does? Drop your own `lock.sh` in `~/.config/pounce/commands/` with the same filename, and yours takes over. A non-`.sh` file (a `.tsv` or `.json` data catalog) is ignored wherever it sits. A helper script that shouldn't itself be a command belongs in a subdirectory (e.g. `data/`), which Pounce doesn't descend into. ## Hiding, renaming and binding a row [#hiding-renaming-and-binding-a-row] Any command, app or built-in mode can be dropped from the list, given a search shorthand, or given a global key of its own, all in one map, in [config](/docs/pounce/config#per-item-settings-items): ```jsonc { "items": { "cmd:brew-services": { "enabled": false }, "cmd:emoji": { "alias": "emo", "hotkey": "opt+space e" } } } ``` # Config reference (/docs/pounce/config) ## Config file [#config-file] Pounce reads `~/.config/pounce/config.json`, re-read on every open. No restart is needed, except for the three things the daemon sets up **at startup**: `windows`, `autoQuit`, and the `items` hotkeys. Those are captured when armed and need a restart to change: ```sh launchctl kickstart -k "gui/$(id -u)/com.hausfold.pounce" ``` Inside haus, a rebuild does that bounce for you when one of those keys moves. ```sh pounce config init # writes ~/.config/pounce/config.json pounce config print # …or just look at it, touching nothing ``` `init` writes **every** setting at its default, one commented-out line each with a sentence above it. Nothing changes until you uncomment a line, and you make the file minimal by deleting the lines you never touched. It never overwrites an existing config (it writes `config.json.new` beside it instead; `--force` replaces). Your `config.json` is generated from `haus.pounce.*`, and the next rebuild would put the generated one straight back. Change it in your host file instead; see [the Launcher room](/docs/haus/rooms/launcher). Comments and trailing commas are fine (JSON5); unknown keys are ignored, so an older Pounce never chokes on a config written by a newer one. ```jsonc { "theme": "nebelung", // "nebelung" (default), "mocha", or a themes/ file "themeLight": "nebelung-latte", // used in macOS Light Mode "themeDark": "nebelung", // used in macOS Dark Mode "windowMode": "default", // "default" (720px) or "compact" (600px, tighter) "scale": 1.0, // 0.8-2.0: how big the whole UI is drawn "hotkey": { "enabled": true, "key": "space", // "space", "return", "tab", "escape", "a"–"z", "0"–"9" "modifiers": ["cmd"] // any of "cmd", "shift", "opt", "ctrl" }, "clipboard": { "enabled": true, "maxEntries": 200, "blacklistBundleIds": ["com.apple.Passwords"], "autoPaste": false // synthesize ⌘V into the prior app (needs Accessibility) }, "quickAnswers": { "currency": true }, // ECB rates, so "100 usd in eur" answers inline "updates": { "check": true }, // nudge (never install) when a release is out "fileSearch": { "enabled": true, "homeOnly": true, // scope to ~ instead of the whole Spotlight index "maxResults": 60 }, "apps": { "demoteBundleIds": [], // sink these below everything else; REPLACES a built-in list of Apple utilities (Feedback Assistant, Audio MIDI Setup, …) "hideBundleIds": [] // drop these from the list entirely }, "windows": { "enabled": false, // the MRU window switcher (needs Accessibility) "key": "tab", "modifiers": ["cmd"] }, "autoQuit": { "enabled": false, "delay": 2, "exclude": ["com.apple.finder"] // REPLACES the default, doesn't extend it }, "items": { // per-item enable / alias / hotkey, see below "cmd:emoji": { "alias": "emo", "hotkey": "opt+space e" } } } ``` `quickAnswers.currency` and `updates.check` are the only two settings that touch the network; set both `false` for a fully offline Pounce. `autoQuit.enabled` and `windows.enabled` turn on [the two opt-in behaviours](/docs/pounce/beyond-launching); both blocks are startup-only, so **any** edit inside them needs the daemon restart above, not just flipping `enabled`. `windowMode` and `scale` are independent knobs (proportions vs. size) and they compose: a compact launcher at `1.4` is still the compact layout, just bigger. Values outside 0.8–2.0 clamp rather than reject. On haus, `scale` is written for you from `haus.ui.scale`. ## Themes [#themes] `themeLight` / `themeDark` are resolved per open, so flipping macOS appearance shows on the next summon; either falls back to `theme`, and `theme` alone pins one palette for both modes. Any `theme` value that isn't built-in resolves to `~/.config/pounce/themes/.json`: a flat catppuccin-style `name → "#hex"` map ([nebelung's](https://github.com/hausfold/nebelung) `palette/*.hex.json` files verbatim), which is how a desktop's `theme.flavor` / `theme.contrast` reach Pounce without a rebuild: ```sh mkdir -p ~/.config/pounce/themes curl -fsSLo ~/.config/pounce/themes/nebelung-latte.json \ https://raw.githubusercontent.com/hausfold/nebelung/main/palette/nebelung-latte.hex.json # config.json: "theme": "nebelung-latte" ``` ## Per-item settings (`items`) [#per-item-settings-items] One map for what you'd otherwise want three keys: hide a row, give it a search shorthand, give it a global key. Each entry is keyed by an **item key**: | Item key | Addresses | | --------------------------- | ------------------------------------------------------------------------------------------ | | `cmd:` | a command script, by filename without `.sh` | | `app:/Applications/Foo.app` | an application, by path | | `mode:` | a built-in window: `launcher`, `clipboard`, `emoji`, `screenshots`, `camera`, `filesearch` | ```jsonc { "items": { "cmd:emoji": { "alias": "emo", "hotkey": "opt+e" }, "cmd:brew-services": { "enabled": false }, "app:/Applications/Ghostty.app": { "alias": "term", "hotkey": "opt+t" }, "mode:clipboard": { "hotkey": "cmd+shift+v" } } } ``` `enabled: false` drops the row without disarming a hotkey bound to it; keeping an item off the list but on a key is a legitimate setup. `alias` is a search shorthand that wins over whatever app fuzzy-matches the same letters. `hotkey` runs the item directly, skipping the palette: the last segment is the key, the rest modifiers, and the object form `{"key": …, "modifiers": …}` works too. The laptop **Fn/Globe key** is a one-step special case (`"mode:emoji": { "hotkey": "fn" }`), using a keyboard event tap that needs Accessibility, firing only on a lone tap so Fn combinations keep working. `"globe"` / `"function"` are accepted aliases; haus binds emoji to it by default, and `haus.pounce.items."mode:emoji".hotkey = null` leaves Globe native. ### Leader sequences [#leader-sequences] Add a space for a two-step key: `"opt+space e"` is ⌥Space then E, the Emacs/VS Code notation. Sequences sharing a leader share it (⌥Space registers once), and can run longer than two steps. The payoff on a tiling setup: a leader can't collide with the ⌥/⌘ chords a window manager already owns, and it needs **no Accessibility grant**: pressing it grabs the next-step keys as ordinary global hotkeys for \~2s and releases them the instant one fires. Escape cancels; hesitate \~0.45s and a which-key overlay lists the next keys. `pounce doctor` reports every binding it actually armed. ### Driving Pounce from another binder [#driving-pounce-from-another-binder] AeroSpace modes, skhd, Shortcuts: ```sh pounce run cmd:emoji pounce run mode:clipboard ``` Same target grammar as `items`, through the same path a native binding takes; a malformed target exits non-zero with a reason. ## File paths [#file-paths] | Path | What | | ------------------------------------- | ---------------------------------- | | `~/.config/pounce/config.json` | Configuration | | `~/.config/pounce/commands/` | Your commands (highest precedence) | | `~/.config/pounce/themes/` | Extra palettes, by name | | `~/.config/pounce/cheatsheet.json` | Optional cheatsheet content | | `~/.local/share/pounce/frecency.json` | Usage history for ranking | | `~/.local/share/pounce/pounce.sock` | Daemon control socket | | `~/.local/state/pounce/drafts/` | Saved drafts, per `--draft` key | ## Environment variables [#environment-variables] Set by packagers (haus), rarely by hand: `POUNCE_BUILTIN_DIR`, `POUNCE_EXTRA_COMMAND_DIRS` (colon-separated Nix layers), `POUNCE_COMMAND_PATH` (colon-separated ad-hoc dirs). # Install (/docs/pounce/install) Pounce is a prebuilt, Developer-ID-signed and notarized `Pounce.app`: no compile step, no Xcode command-line tools to fetch, no Gatekeeper prompt. ## Homebrew [#homebrew] ```sh brew tap hausfold/tap brew install pounce brew services start pounce # start the daemon pounce --request-accessibility # one-time, approve the dialog ``` Because that signing identity is stable, the Accessibility grant **survives `brew upgrade`**: approve it once, not once per release. ## Or a download [#or-a-download] The [latest release](https://hausfold.co/download/pounce) is the same build as a DMG: drag it to Applications and open it. First launch registers a login item and starts the daemon; approve the login item in System Settings if macOS asks. With Nix, without installing anything: `nix run github:hausfold/pounce -- --help`. ## What it needs [#what-it-needs] | | | | ----------------- | ------------------------------------------------ | | **macOS 14** | Sonoma or newer, on Apple Silicon | | **Accessibility** | One permission (see below) | | **Nothing else** | No account, no key, no sign-in, no Xcode, no Nix | ## One key to free up [#one-key-to-free-up] Pounce wants **⌘Space**, and on a stock Mac that key belongs to Spotlight. Spotlight wins **silently**: registration *succeeds*, so Pounce can't tell, and macOS just routes the key elsewhere. The daemon checks for this at startup and names the shortcut that beat it in its log. Free the key in **System Settings → Keyboard → Keyboard Shortcuts → Spotlight**, then restart the daemon. Would you rather keep Spotlight where it is? The hotkey is a line of [config](/docs/pounce/config#config-file); the `hotkey` block takes a `key` and a list of `modifiers`: ```jsonc { "hotkey": { "key": "space", "modifiers": ["opt"] } } // ⌥Space instead ``` (On a haus machine you write `haus.keys.palette = "alt-space"` instead, and the rebuild generates that block for you.) Inside [haus](/docs/haus/rooms/launcher) ⌘Space is handed over for you either way, because haus turns Spotlight's binding off. ## Grant Accessibility [#grant-accessibility] Clipboard auto-paste, emoji paste, and modifier-only bindings like Fn/Globe all synthesize keystrokes; the [window switcher and auto-quit](/docs/pounce/beyond-launching) read a live window snapshot. macOS gates all of it behind one grant: ```sh pounce --request-accessibility # approve the dialog pounce --check-accessibility # prints true when granted ``` Pounce is honest about why it asks: a global hotkey that works over every app can't be had without it. Two features ask for their own the first time you use them: the camera peek, and the Bluetooth plugin. Nothing else: no Input Monitoring, no Screen Recording, no Full Disk Access. A plain Nix rebuild is ad-hoc signed, which would drop the grant every time. Inside haus, set `haus.pounce.signingIdentity` and the desktop re-signs a stable copy of the app with your identity, so the grant persists across rebuilds. See [the Launcher room](/docs/haus/rooms/launcher). ## Check it landed [#check-it-landed] ```sh pounce doctor ``` It reports whether the daemon is up, whether Accessibility is granted, every binding it actually armed, and what beat it to a hotkey if something did. # Using the palette (/docs/pounce/using) ## Summon it [#summon-it] * **⌘Space**: the default hotkey, registered in-process by the daemon so it opens instantly. * **Fn/Globe**: a lone tap opens the emoji and symbols grid. Fn combinations keep working; this reuses Pounce's Accessibility grant and only replaces Globe's stock action while armed. (On by default inside haus; `"mode:emoji": { "hotkey": "fn" }` elsewhere.) * Change either in [config](/docs/pounce/config#config-file): the `hotkey` block takes a `key` and `modifiers`. Not opening at all? [Install](/docs/pounce/install#one-key-to-free-up) covers the ⌘Space collision with Spotlight, which fails silently. ## Navigating [#navigating] | Key | Action | | --------------------- | ------------------------------------------------------- | | Type | Fuzzy-search in real time | | `↑` / `↓` | Move the selection | | `Return` | Run the default action | | `⇧Return` | Insert a newline; the query field is multi-line | | `⌘Return` | The modifier action (often "Reveal in Finder" for apps) | | `⌥Return` / `⌃Return` | Alternate actions, when shown | | `Esc` | Dismiss | The action bar at the bottom always shows which modifiers do what for the selected row. ## How ranking works [#how-ranking-works] Three signals decide the order, so the thing you want is usually already on top: * **Fuzzy match quality**: consecutive runs, word-boundary hits, and titles counting double subtitles. * **Frecency**: frequency × recency, on a 72-hour half-life. * **A freshly-installed boost** for anything installed in the last week, halving every two days. An app you'd rather never see can be hidden or sunk with `apps.hideBundleIds` / `apps.demoteBundleIds`, and any row can take a search shorthand; see [per-item settings](/docs/pounce/config#per-item-settings-items). ## Quick answers [#quick-answers] Some queries are answered **in the palette** instead of being fuzzy-matched against apps and commands. The answer pins to the top as its own row, and `Return` copies it (no trigger prefix needed): | Type | Get | | -------------------------------------------- | -------------------- | | `2*847`, `2^10`, `sqrt(9)` | Arithmetic | | `100 + 10%`, `20% of 150` | Percentages | | `72 f in c`, `1 pound in kg`, `1 gib in mib` | Unit conversion | | `100 usd in eur`, `$100 in eur` | Currency conversion | | `14:00 utc in pst`, `2pm utc in tokyo` | Time-zone conversion | A query no engine can parse just falls through to normal results, and anything without a digit skips the engines entirely: `safari` stays a search. Currency uses the European Central Bank's daily reference rates, cached locally so it keeps answering offline. It is one of only two things that touch the network (the other is the hourly release check), and `quickAnswers.currency` in [config](/docs/pounce/config#config-file) turns it off. ## The built-in modes [#the-built-in-modes] The palette is not the only window Pounce draws. Each of these is an item you can run from the palette, bind to a key of its own, or launch from a script: | Mode | What it is | | ------------------ | -------------------------------------------------------------------------- | | `mode:clipboard` | Clipboard history, two-pane, searchable, with optional auto-paste | | `mode:emoji` | Emoji *and* plain-text symbols in one grid: `command` → ⌘, `not equal` → ≠ | | `mode:filesearch` | Files and folders by name, over Spotlight's index | | `mode:screenshots` | A browser for the shots you've taken | | `mode:camera` | A live camera peek | `pounce run mode:clipboard` opens one directly; `"mode:clipboard": { "hotkey": "cmd+shift+v" }` in [config](/docs/pounce/config#per-item-settings-items) gives it a key. The cheatsheet overlay (`pounce --cheatsheet`) is the sixth window, and takes its content from `~/.config/pounce/cheatsheet.json`. # Writing your own command (/docs/pounce/writing-commands) Pounce has no plugin SDK. A command is **one shell script** with an optional metadata header. Drop it in a folder and it is in the palette on the next open, with no build step, no restart, no manifest: ```bash #!/bin/bash # pounce: name = Say Hello # pounce: description = A friendly notification # pounce: icon = hand.wave osascript -e 'display notification "🐾" with title "Pounce"' ``` Put it in `~/.config/pounce/commands/`, summon the palette, type `hello`. The registry re-scans every time you summon. Scripts don't need the executable bit (Pounce runs them with `bash` either way), but they **must** end in `.sh`, or Pounce never lists them. | Header key | Meaning | Default | | ------------- | ------------------------------------------------------------ | ---------------------------- | | `name` | Title shown in the palette | the filename (without `.sh`) | | `description` | Subtitle | *(empty)* | | `icon` | An [SF Symbol](https://developer.apple.com/sf-symbols/) name | `sparkles` | | `submenu` | `true` means the command re-invokes Pounce for a second step | `false` | Parsing stops at the first non-header line, within the first 30 lines. ## Submenus and chaining [#submenus-and-chaining] Set `submenu = true` and pipe your options through `pounce` again; the list swaps in place with no flicker: ```bash #!/bin/bash # pounce: name = Brew Services # pounce: submenu = true service=$(list_services | pounce -p "Service:") [ -n "$service" ] && toggle_service "$service" ``` When the second step is a **search** rather than a list, pass `--chain`: on an empty match Enter hands the raw text back, and `--chain` tells Pounce that text feeds another `pounce`, holding the window's loading skeleton instead of fading between steps: ```bash query=$(printf '' | pounce --chain -p "App Store — type a search, then Enter") [ -n "$query" ] && mas search "$query" | pounce -p "Install:" ``` ## A step that takes a paragraph [#a-step-that-takes-a-paragraph] A step whose answer is a sentence wants more than a filter box. Three flags turn a `pounce` step into one, and haus's **Spawn Agent** command is the worked example: * `--actions` labels more than one verb on a rowless prompt. Return hands back `\ttext`, where action is `enter` / `cmd` / `opt` / `ctrl`. * `⇧↵` inserts a newline, and the box grows with the text. * `--draft ` files the query on every non-commit dismissal; `pounce drafts get ` reads it back, and `--query ` reopens the box pre-filled for editing. ```sh sel=$(printf '' | pounce --chain enter,opt --draft my-prompt \ --actions "Go|shift:New line|cmd:With a screenshot|opt:Drafts" \ -p "What should it do?") case "$(printf '%s' "$sel" | cut -f1)" in enter) go "$(printf '%s' "$sel" | cut -f2-)" ;; cmd) shot "$(printf '%s' "$sel" | cut -f2-)" ;; opt) show_drafts ;; esac ``` ## Pounce as a generic picker [#pounce-as-a-generic-picker] Beyond commands, `pounce` is a dmenu-style picker: pipe it lines, get the chosen one on stdout. ```sh printf 'a\nb\nc\n' | pounce -p "pick one:" ``` A piped list keeps **your order** (only the launcher's own apps get sorted), so a ranking your script already computed survives into the picker. Each line can carry extra tab-separated columns: ``` title subtitle icon actions group ``` `actions` is `label | key:label | key:label…` (the first is Return, the rest are modifier combos); `group` is an optional section header, as Force Quit does with *Applications* / *Background*. ## Reading real ones [#reading-real-ones] The [built-in commands](https://github.com/hausfold/pounce/tree/main/pkgs/pounce-commands/commands) are copy-pasteable examples of submenus, grouping and icons, and they are the same kind of file yours is, in the same format, with no privileged API between them and you. # What trill is (/docs/trill) There is nothing to install, no release and no download. This page exists so the idea has an address while it is being built. Everything below is intent, not a shipped feature, and any of it may change or be dropped. **trill** is a notification compositor for macOS: one place for the things that interrupt you, with a filter in front of it that you write rather than negotiate with an app at a time. The problem it is aimed at is not that Macs notify too much. It's that notifications arrive from a dozen apps with a dozen different ideas of what is urgent, and macOS gives you one lever per app: on, or off. What's missing is a place to say *this one matters, that one can wait until I look*. ## Where it is [#where-it-is] * **The repo is [`hausfold/trill`](https://github.com/hausfold/trill)**, created 2026-08-09. It is public, and the commit log is the honest status. * **It was called `flick`** while it incubated in the workshop, so both names appear in older writing. * ⚠️ **`nebelhaus/trill` is not it.** That name now resolves to `nebelhaus/messages`, an archived iMessage client (a different project entirely). Typing the old org for trill lands you on a tombstone, silently. ## What it isn't yet [#what-it-isnt-yet] No install command, no `haus.trill.*` options, no room. When there is something a stranger can run, this tree grows the pages to say so. The [Focus room](/docs/haus/rooms/focus), which is haus's answer to "go quiet" today, is where that story starts.