hausfold

haus

haus.* options

Every option you can set in your host file — types, defaults, and what each one changes.

These are the haus.* options you set in your host file at ~/.config/nix/hosts/<hostname>/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

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

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

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.

haus.apps

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

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:

true
Declared in modules/apps/options.nix.

haus.apps.videoPlayer.claimFileTypes

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.

haus.apps.videoPlayer.enable

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.

haus.homebrew

How rebuilds treat Homebrew packages you did not declare.

haus.homebrew.autoUpdate

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.

haus.homebrew.cleanup

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.

haus.homebrew.upgrade

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.

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

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

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:

true
Declared in modules/appearance/options.nix.

haus.theme

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

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:

"sapphire"
Declared in modules/theme/options.nix.

haus.theme.contrast

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:

"high"
Declared in modules/theme/options.nix.

haus.theme.flavor

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:

"latte"
Declared in modules/theme/options.nix.

haus.theme.ports.enable

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.

haus.theme.systemAppearance

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:

"flavor"
Declared in modules/theme/options.nix.

haus.wallpaper

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

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:

"#0b0b0e"
Declared in modules/wallpaper/options.nix.

haus.wallpaper.debug.enable

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:

true
Declared in modules/wallpaper/options.nix.

haus.wallpaper.debug.inputs

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:

[
  "self"
  "nixpkgs"
]
Declared in modules/wallpaper/options.nix.

haus.wallpaper.debug.inset

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:

96
Declared in modules/wallpaper/options.nix.

haus.wallpaper.debug.size

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:

0.02
Declared in modules/wallpaper/options.nix.

haus.wallpaper.depth

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:

0
Declared in modules/wallpaper/options.nix.

haus.wallpaper.glow.color

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:

"#8db4f3"
Declared in modules/wallpaper/options.nix.

haus.wallpaper.glow.enable

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.

haus.wallpaper.glow.spread

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:

0.6
Declared in modules/wallpaper/options.nix.

haus.wallpaper.glow.strength

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:

14
Declared in modules/wallpaper/options.nix.

haus.wallpaper.grain

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:

0.0
Declared in modules/wallpaper/options.nix.

haus.wallpaper.mark.color

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:

"muted"
Declared in modules/wallpaper/options.nix.

haus.wallpaper.mark.enable

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.

haus.wallpaper.mark.opacity

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:

0.55
Declared in modules/wallpaper/options.nix.

haus.wallpaper.mark.rise

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:

0.06
Declared in modules/wallpaper/options.nix.

haus.wallpaper.mark.size

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:

0.3
Declared in modules/wallpaper/options.nix.

haus.wallpaper.mark.weight

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:

0.055
Declared in modules/wallpaper/options.nix.

haus.wallpaper.size

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:

"3024x1964"
Declared in modules/wallpaper/options.nix.

haus.wallpaper.style

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:

"minimal"
Declared in modules/wallpaper/options.nix.

haus.fonts

The terminal font. The bar keeps its own font at its own tuned sizes.

haus.fonts.mono.baseSize

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:

19
Declared in modules/den/options.nix.

haus.fonts.mono.name

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:

"Berkeley Mono"
Declared in modules/den/options.nix.

haus.fonts.mono.package

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:

pkgs.nerd-fonts.fira-code
Declared in modules/den/options.nix.

haus.fonts.mono.packageName

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:

"nerd-fonts.fira-code"
Declared in modules/den/options.nix.

haus.fonts.mono.size

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:

24
Declared in modules/den/options.nix.

haus.accessibility

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

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:

true
Declared in modules/den/options.nix.

haus.accessibility.closeViewZoomFollowsFocus

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:

true
Declared in modules/den/options.nix.

haus.accessibility.differentiateWithoutColor

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:

true
Declared in modules/den/options.nix.

haus.accessibility.increaseContrast

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:

true
Declared in modules/den/options.nix.

haus.accessibility.mouseDriverCursorSize

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:

4.0
Declared in modules/den/options.nix.

haus.accessibility.reduceMotion

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.accessibilityDisplayShouldReduceMotionhausax 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:

true
Declared in modules/den/options.nix.

haus.accessibility.reduceTransparency

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:

true
Declared in modules/den/options.nix.

haus.animations

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

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:

"fast"
Declared in modules/den/options.nix.

haus.screenshots

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

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:

"png"
Declared in modules/den/options.nix.

haus.screenshots.includeDate

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:

true
Declared in modules/den/options.nix.

haus.screenshots.location

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:

"~/Pictures/Screenshots"
Declared in modules/den/options.nix.

haus.screenshots.shadow

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:

false
Declared in modules/den/options.nix.

haus.screenshots.thumbnail

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:

false
Declared in modules/den/options.nix.

haus.sound

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

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:

"Submarine"
Declared in modules/den/options.nix.

haus.sound.alertVolume

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:

50
Declared in modules/den/options.nix.

haus.sound.startupChime

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:

false
Declared in modules/den/options.nix.

haus.sound.uiSounds

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:

false
Declared in modules/den/options.nix.

haus.sound.volumeFeedback

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:

true
Declared in modules/den/options.nix.

Displays

Resolution and per-display behaviour, addressed by which screen you mean rather than by a panel's serial number.

haus.displays

Per-display overrides, keyed by which screen you mean.

haus.displays

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 <uuid> 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.<uuid> 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:

{
  "37D8832A-2D66-02CA-B9F7-8F30A301B230" = {
    uiScale = "more-space";
  };
  internal = {
    uiScale = "larger-text";
  };
}
Declared in modules/displays/options.nix.

haus.displays.<name>.uiScale

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:

"larger-text"
Declared in modules/displays/options.nix.

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

The shell and terminal experience.

haus.hearth.editor

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:

"code -w"
Declared in modules/hearth/options.nix.

haus.hearth.editorName

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:

"neovim"
Declared in modules/hearth/options.nix.

haus.hearth.floatBorder

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:

"grey"
Declared in modules/hearth/options.nix.

haus.hearth.ghDash.enable

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:

true
Declared in modules/hearth/options.nix.

haus.hearth.hijackFileAssociations

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.

haus.hearth.lanes.backend

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.<repo>.<lane>. 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 <name> 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.

haus.hearth.obsidianVaults

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:

[
  "Library/Mobile Documents/iCloud~md~obsidian/Documents/notes"
]
Declared in modules/hearth/options.nix.

haus.hearth.rightClickFullscreen

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.

haus.hearth.zellijStartLocked

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.

haus.zen

Zen browser policy, extensions and the optional native tab bridge.

haus.zen.extensions

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:

{
  # 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.

haus.zen.extensions.<name>.enable

boolean · default true

Whether to deploy this extension. Set false to remove one an imported desktop added.

Declared in modules/hearth/options.nix.

haus.zen.extensions.<name>.id

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:

"{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}"
Declared in modules/hearth/options.nix.

haus.zen.extensions.<name>.mode

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.

haus.zen.extensions.<name>.slug

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:

"styl-us"
Declared in modules/hearth/options.nix.

haus.zen.extensions.<name>.url

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.

haus.zen.extraPolicies

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:

{ DisableTelemetry = true; }
Declared in modules/hearth/options.nix.

haus.zen.tabBridge.enable

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.

haus.developer

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

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:

true
Declared in modules/options.nix.

haus.developer.git.enable

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.

haus.developer.languages

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:

[ ]
Declared in modules/options.nix.

haus.developer.toolbelt.enable

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.

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

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

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:

"mission-control"
Declared in modules/den/options.nix.

haus.hotCorners.bottomRight

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:

"mission-control"
Declared in modules/den/options.nix.

haus.hotCorners.topLeft

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:

"mission-control"
Declared in modules/den/options.nix.

haus.hotCorners.topRight

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:

"mission-control"
Declared in modules/den/options.nix.

haus.prowl

Tiling window management and the Caps-Lock leader launcher.

haus.prowl.enable

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.

Bar

The menu bar: where it draws, which pills it carries, and what each one reads.

haus.menuBar

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

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:

false
Declared in modules/den/options.nix.

haus.menuBar.clock.format

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:

"24h"
Declared in modules/den/options.nix.

haus.menuBar.clock.showDate

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:

"always"
Declared in modules/den/options.nix.

haus.menuBar.clock.showDayOfWeek

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:

true
Declared in modules/den/options.nix.

haus.menuBar.clock.showSeconds

null or boolean · default null

Show the clock to second precision instead of minutes. null (the default) leaves macOS's own choice alone.

Example:

false
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.airdrop

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:

false
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.batteryPercentage

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:

true
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.bluetooth

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:

true
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.displayBrightness

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:

true
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.focus

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:

true
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.nowPlaying

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:

false
Declared in modules/den/options.nix.

haus.menuBar.controlCenter.sound

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:

true
Declared in modules/den/options.nix.

haus.sill

The menu bar, and which pills it draws.

haus.sill.aiUsage.provider

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:

"claude"
Declared in modules/sill/options.nix.

haus.sill.battery.hideOver

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:

80
Declared in modules/sill/options.nix.

haus.sill.bottom.enable

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:

true
Declared in modules/sill/options.nix.

haus.sill.bottom.items

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:

{
  agents = "left";
  clock = "right";
  media = "center";
}
Declared in modules/sill/options.nix.

haus.sill.bottom.items.agents

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.

haus.sill.bottom.items.aiUsage

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.

haus.sill.bottom.items.battery

boolean or one of "left", "center", "right" · default false

The battery pill.

Declared in modules/sill/options.nix.

haus.sill.bottom.items.caffeinate

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.

haus.sill.bottom.items.calendar

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.

haus.sill.bottom.items.clock

boolean or one of "left", "center", "right" · default false

The clock pill, pinned to the far right.

Declared in modules/sill/options.nix.

haus.sill.bottom.items.cpu

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.

haus.sill.bottom.items.elgato

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.

haus.sill.bottom.items.github

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.

haus.sill.bottom.items.harvest

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.

haus.sill.bottom.items.hush

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.

haus.sill.bottom.items.media

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.

haus.sill.bottom.items.memory

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.

haus.sill.bottom.items.volume

boolean or one of "left", "center", "right" · default false

Output volume / mute state.

Declared in modules/sill/options.nix.

haus.sill.bottom.items.weather

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.

haus.sill.bottom.items.wifi

boolean or one of "left", "center", "right" · default false

The Wi-Fi status pill.

Declared in modules/sill/options.nix.

haus.sill.calendar.horizon

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:

12
Declared in modules/sill/options.nix.

haus.sill.calendar.imminent

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:

2
Declared in modules/sill/options.nix.

haus.sill.calendar.joinHosts

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:

[
  "meet.mycorp.example"
]
Declared in modules/sill/options.nix.

haus.sill.calendar.me

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:

[
  "you@work.example"
]
Declared in modules/sill/options.nix.

haus.sill.calendar.past

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:

8
Declared in modules/sill/options.nix.

haus.sill.calendar.preciseUnder

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:

3
Declared in modules/sill/options.nix.

haus.sill.calendar.refresh

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:

60
Declared in modules/sill/options.nix.

haus.sill.calendar.upcoming

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:

3
Declared in modules/sill/options.nix.

haus.sill.calendar.width

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 · <event>"; 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:

16
Declared in modules/sill/options.nix.

haus.sill.clock.mode

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:

"compact"
Declared in modules/sill/options.nix.

haus.sill.clock.monoFont

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:

false
Declared in modules/sill/options.nix.

haus.sill.elgato.host

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:

"elgato-key-light-mini-57a3.local"
Declared in modules/sill/options.nix.

haus.sill.enable

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.

haus.sill.github.refresh

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:

900
Declared in modules/sill/options.nix.

haus.sill.github.sources

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:

[
  { 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.

haus.sill.github.sources.*.ci

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:

true
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.command

null or string · default null

A command run through bash -c, printing one row per line as <state>\t<text> or <state>\t<text>\t<url>, 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:

"gh api /notifications --jq '.[] | \"warn\\t\" + .subject.title'"
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.icon

string · default ""

The glyph beside that section's heading, in the bar's Nerd Font. Empty takes a default for the kind.

Example:

""
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.limit

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:

5
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.org

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:

"hausfold"
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.search

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:

"org:hausfold is:pr is:open"
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.severity

null or one of "info", "warn", "bad" · default ``badfor acisource,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:

"bad"
Declared in modules/sill/options.nix.

haus.sill.github.sources.*.title

string · default ""

The dropdown section's heading. Empty derives one: the owner and "default branches" for ci, the filter itself for search.

Example:

"red on main"
Declared in modules/sill/options.nix.

haus.sill.items

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:

{
  cpu = true;
  weather = false;
}
Declared in modules/sill/options.nix.

haus.sill.items.agents

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.

haus.sill.items.aiUsage

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.

haus.sill.items.battery

boolean · default true

The battery pill.

Declared in modules/sill/options.nix.

haus.sill.items.caffeinate

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.

haus.sill.items.calendar

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.

haus.sill.items.claudeUsage

boolean · default false

Deprecated alias for aiUsage.

Declared in modules/sill/options.nix.

haus.sill.items.clock

boolean · default true

The clock pill, pinned to the far right.

Declared in modules/sill/options.nix.

haus.sill.items.cpu

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.

haus.sill.items.elgato

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.

haus.sill.items.github

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.

haus.sill.items.harvest

boolean · default false

A Harvest time-tracking pill; needs a ~/.config/sketchybar/harvest_secrets.sh you provide.

Declared in modules/sill/options.nix.

haus.sill.items.media

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.

haus.sill.items.memory

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.

haus.sill.items.volume

boolean · default false

Output volume / mute state.

Declared in modules/sill/options.nix.

haus.sill.items.weather

boolean · default true

The weather pill and its click-to-open forecast popover.

Declared in modules/sill/options.nix.

haus.sill.items.wifi

boolean · default true

The Wi-Fi status pill.

Declared in modules/sill/options.nix.

haus.sill.logo.color

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:

"teal"
Declared in modules/sill/options.nix.

haus.sill.logo.gestures

boolean · default true

What the logo pill does when clicked:

gesturewhat it opens
left clickthe haus menu — System Settings, Activity Monitor, Lock Screen, Nix Config, Haus Settings, Rebuild System, Reload SketchyBar
⌘ left clickhaus rebuild, straight into a floating terminal
right clickthe 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.

haus.sill.logo.icon

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:

glyphcodepointwhat it is
U+F015nf-fa-home — solid house (the default)
U+F46Dnf-oct-home — outlined house at icon weight
U+EB06nf-cod-home — the same, slightly rounder
U+2302the hausfold mark, hairline
U+F302nf-fa-apple — the logo this pill replaced
U+F313nf-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:

"⌂"
Declared in modules/sill/options.nix.

haus.sill.logo.size

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:

25
Declared in modules/sill/options.nix.

haus.sill.logo.status

boolean · default true

Let the logo's colour report the health of the machine, so the pill says something without being clicked:

colourmeaning
accenteverything haus runs is up
yellowa newer haus is pinned upstream (needs haus.sill.logo.updateCheck)
redsomething 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 pgreps 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.

haus.sill.logo.sweep

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.

haus.sill.logo.updateCheck

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:

true
Declared in modules/sill/options.nix.

haus.sill.media.artworkTint

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:

true
Declared in modules/sill/options.nix.

haus.sill.media.collapse

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:

true
Declared in modules/sill/options.nix.

haus.sill.media.icons

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:

{
  "browser.video" = "󰗃";
  "com.apple.podcasts" = "󰦔";
}
Declared in modules/sill/options.nix.

haus.sill.media.width

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:

16
Declared in modules/sill/options.nix.

haus.sill.position

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:

"auto"
Declared in modules/sill/options.nix.

Launcher

Pounce — the command palette, its daemon, its commands, and every Pounce setting haus exposes.

haus.pounce

The ⌘Space command palette.

haus.pounce.autoQuit.delay

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:

5
Declared in modules/pounce/options.nix.

haus.pounce.autoQuit.enable

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.

haus.pounce.autoQuit.exclude

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:

[
  "com.apple.finder"
  "com.docker.docker"
  "com.spotify.client"
]
Declared in modules/pounce/options.nix.

haus.pounce.enable

boolean · default false

The pounce command palette daemon (⌘Space) + the palette commands haus ships.

Declared in modules/pounce/options.nix.

haus.pounce.followSystemAppearance

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.

haus.pounce.items

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:<id>" a command, by script name without .sh "app:/Applications/Foo.app" an application, by path "mode:<name>" 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:<id> 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:

{
  "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.

haus.pounce.items.<name>.alias

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:

"emo"
Declared in modules/pounce/options.nix.

haus.pounce.items.<name>.caption

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:

"Clipboard history"
Declared in modules/pounce/options.nix.

haus.pounce.items.<name>.hotkey

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:

"opt+space e"
Declared in modules/pounce/options.nix.

haus.pounce.items.<name>.listed

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.

haus.pounce.scale

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.<name>.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:

1.4
Declared in modules/pounce/options.nix.

haus.pounce.signingIdentity

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:

"Developer ID Application: Jane Doe (ABCDE12345)"
Declared in modules/pounce/options.nix.

haus.pounce.windowMode

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.

haus.pounce.windowSwitcher

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.

Shelf

Perch — the file shelf that grows out of the notch to catch what you drag at it.

haus.perch

The notch file shelf.

haus.perch.enable

boolean · default false

The perch notch file shelf, installed via the perch flake (copied to /Applications).

Declared in modules/perch/options.nix.

haus.perch.followSystemAppearance

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.

Focus

One quiet switch: Do Not Disturb, an optional status somewhere else, and your own hooks on both edges.

haus.hush

One quiet switch: Do Not Disturb, optional Slack status, and your hooks.

haus.hush.enable

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.

haus.hush.hooks

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:

[ ./onair-light.sh "/Users/ada/bin/pause-music" ]
Declared in modules/hush/options.nix.

haus.hush.slack.enable

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.

haus.hush.slack.snooze

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.

haus.hush.slack.statusEmoji

string · default ":no_bell:"

Slack status emoji while hushed.

Declared in modules/hush/options.nix.

haus.hush.slack.statusText

string · default "heads down"

Slack status text while hushed.

Declared in modules/hush/options.nix.

haus.hush.slack.tokenCommand

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:

"security find-generic-password -s hush-slack -w"
Declared in modules/hush/options.nix.

AI

Coding agents: which clients this machine installs, the worktree lifecycle around them, and the instructions and haus skill every client reads.

haus.ai

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

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:

[
  "claude"
  "codex"
]
Declared in modules/ai/options.nix.

haus.ai.default

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:

"codex"
Declared in modules/ai/options.nix.

haus.ai.enable

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.

haus.ai.instructions

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 <file>.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:

''
  # How I work
  Ship small, verified changes; ask before anything hard to reverse…
''
Declared in modules/ai/options.nix.

haus.ai.skill

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.

Text expansion

Snippets, and the engine that types them out for you.

haus.snippets

Text expansion via espanso.

haus.snippets.enable

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.

haus.snippets.matches

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:

[
  { trigger = "@@"; replace = "ada@example.com"; }
  { trigger = "##"; replace = "+1 555 0100"; }
]
Declared in modules/snippets/options.nix.

haus.snippets.matches.*.replace

string · no default

What it expands to.

Example:

"ada@example.com"
Declared in modules/snippets/options.nix.

haus.snippets.matches.*.trigger

string · no default

What you type.

Example:

"@@"
Declared in modules/snippets/options.nix.

Security

Touch ID for sudo, lock behaviour, the firewall, and where secret values come from.

haus.lock

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

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:

true
Declared in modules/den/options.nix.

haus.lock.requirePasswordDelay

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:

5
Declared in modules/den/options.nix.

haus.security

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

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:

true
Declared in modules/den/options.nix.

haus.security.firewall.allowSignedApp

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:

true
Declared in modules/den/options.nix.

haus.security.firewall.blockAllIncoming

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:

false
Declared in modules/den/options.nix.

haus.security.firewall.enable

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:

true
Declared in modules/den/options.nix.

haus.security.firewall.stealthMode

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:

true
Declared in modules/den/options.nix.

haus.collar

Touch ID for sudo — including inside a terminal multiplexer — and the passwordless-rebuild rule.

haus.collar.enable

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.

haus.collar.passwordlessRebuild

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.

haus.secrets

Where secret values come from on this machine.

haus.secrets.provider

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:

"gcsm"
Declared in modules/secrets/options.nix.

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

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

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.<id>.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:

{
  # 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.

haus.roster.<name>.appId

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:

"com.tinyspeck.slackmacgap"
Declared in modules/options.nix.

haus.roster.<name>.appStoreId

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:

497799835
Declared in modules/options.nix.

haus.roster.<name>.brew

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:

"ical-buddy"
Declared in modules/options.nix.

haus.roster.<name>.cask

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:

"slack"
Declared in modules/options.nix.

haus.roster.<name>.enable

boolean · default true

Whether this app participates in the shared launcher roster.

Declared in modules/options.nix.

haus.roster.<name>.float

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.

haus.roster.<name>.installedBy

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:

"haus.perch"
Declared in modules/options.nix.

haus.roster.<name>.key

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:

"s"
Declared in modules/options.nix.

haus.roster.<name>.label

null or string · default null

Cheatsheet caption for the leader key. null uses name.

Example:

"Slack"
Declared in modules/options.nix.

haus.roster.<name>.name

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:

"Slack"
Declared in modules/options.nix.

haus.roster.<name>.order

signed integer · default 1000

Roster order; lower values appear first. Ties are sorted by app id.

Declared in modules/options.nix.

haus.roster.<name>.package

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:

pkgs.orbstack
Declared in modules/options.nix.

haus.roster.<name>.packageName

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:

"orbstack"
Declared in modules/options.nix.

haus.roster.<name>.scope

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.

haus.roster.<name>.titleRegex

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:

"^Picture in Picture$"
Declared in modules/options.nix.

haus.workspaces

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

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:

{
  # 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.

haus.workspaces.<name>.apps

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:

[
  "slack"
  "mail"
  "messages"
]
Declared in modules/options.nix.

haus.workspaces.<name>.icon

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:

":slack:"
Declared in modules/options.nix.

haus.workspaces.<name>.key

null or string · default null

Leader then ⇧<key> throws the focused window to this workspace and follows it there (AeroSpace's move-node-to-workspace --focus-follows-window). There is no bare <key> 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 ⇧<key> must not collide with a built-in launch-mode binding (⇧1-4 are taken).

Example:

"c"
Declared in modules/options.nix.

haus.ui

One number for "make the interface bigger", applied across haus's own surfaces.

haus.ui.scale

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.<name>.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:

1.35
Declared in modules/options.nix.

haus.keys

The keys haus owns — the leader, the palette, the window-chord modifier — and anything extra you hang off the leader.

haus.keys.leader

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:

"none"
Declared in modules/options.nix.

haus.keys.leaderExtras

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:

[
  {
    key = "enter";
    command = "osascript -e 'tell application \"Things3\" to show quick entry panel'";
    caption = "Things Quick Entry";
  }
]
Declared in modules/options.nix.

haus.keys.leaderExtras.*.caption

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:

"Things Quick Entry"
Declared in modules/options.nix.

haus.keys.leaderExtras.*.command

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:

"osascript -e 'tell application \"Things3\" to show quick entry panel'"
Declared in modules/options.nix.

haus.keys.leaderExtras.*.key

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:

"enter"
Declared in modules/options.nix.

haus.keys.palette

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:

"none"
Declared in modules/options.nix.

haus.keys.windowNav

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 (<mod> + hjkl), layouts (<mod> + / ,), fullscreen, moving a workspace to the next monitor (<mod>⇧⇥), and entering service mode (<mod>⇧;). 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 <mod>⇥ 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:

"ctrl-alt"
Declared in modules/options.nix.

haus.tour

The first-run tutor.

haus.tour.enable

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.

haus.tour.steps

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:

[
  {
    detect = "palette";
    hint = "Press {palette}, type tour, then hit ↵";
  }
]
Declared in modules/sill/options.nix.

haus.tour.steps.*.detect

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:

"palette"
Declared in modules/sill/options.nix.

haus.tour.steps.*.hint

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:

"Press {palette}, type calendar, then hit ↵"
Declared in modules/sill/options.nix.

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

Your commit identity, plus the GitHub owner this machine's work lives under — set your own. It stays in your host file.

haus.git.email

string · default ""

Git user.email for commits.

Example:

"ada@example.com"
Declared in modules/hearth/options.nix.

haus.git.name

string · default ""

Git user.name for commits (hearth wires it into home-manager).

Example:

"Ada Lovelace"
Declared in modules/hearth/options.nix.

haus.git.org

string · default ""

The GitHub owner whose repos this machine works on. An organisation, or your own account: GitHub's issue search treats org:<user> the same as user:<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.<user>, where config is home-manager's and carries no haus.* at all.

Example:

"nebelhaus"
Declared in modules/hearth/options.nix.

haus.git.shellAliases

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:

{
  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.

haus.git.signingKey

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:

"6F7BD6F43A7C1420"
Declared in modules/hearth/options.nix.

haus.locale

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

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:

"24h"
Declared in modules/den/options.nix.

haus.locale.inputSources

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:

[
  "com.apple.keylayout.US"
  "com.apple.keylayout.German"
]
Declared in modules/den/options.nix.

haus.locale.language

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:

[
  "de-DE"
  "en-GB"
]
Declared in modules/den/options.nix.

haus.locale.metric

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:

true
Declared in modules/den/options.nix.

haus.locale.region

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:

"de_DE"
Declared in modules/den/options.nix.

haus.locale.temperature

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:

"celsius"
Declared in modules/den/options.nix.

haus.power

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

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:

10
Declared in modules/den/options.nix.

haus.power.computerSleep.charger

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:

10
Declared in modules/den/options.nix.

haus.power.diskSleep.battery

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:

10
Declared in modules/den/options.nix.

haus.power.diskSleep.charger

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:

10
Declared in modules/den/options.nix.

haus.power.displaySleep.battery

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:

10
Declared in modules/den/options.nix.

haus.power.displaySleep.charger

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:

10
Declared in modules/den/options.nix.

haus.power.lowPowerMode.battery

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:

true
Declared in modules/den/options.nix.

haus.power.lowPowerMode.charger

null or boolean · default null

Low Power Mode while plugged in. null (the default) leaves macOS's own choice alone.

Example:

false
Declared in modules/den/options.nix.

On this page

Appshaus.appStorehaus.appStore.installhaus.appshaus.apps.packs.writing.enablehaus.apps.videoPlayer.claimFileTypeshaus.apps.videoPlayer.enablehaus.homebrewhaus.homebrew.autoUpdatehaus.homebrew.cleanuphaus.homebrew.upgradeAppearancehaus.appearancehaus.appearance.largePrinthaus.themehaus.theme.accenthaus.theme.contrasthaus.theme.flavorhaus.theme.ports.enablehaus.theme.systemAppearancehaus.wallpaperhaus.wallpaper.backgroundhaus.wallpaper.debug.enablehaus.wallpaper.debug.inputshaus.wallpaper.debug.insethaus.wallpaper.debug.sizehaus.wallpaper.depthhaus.wallpaper.glow.colorhaus.wallpaper.glow.enablehaus.wallpaper.glow.spreadhaus.wallpaper.glow.strengthhaus.wallpaper.grainhaus.wallpaper.mark.colorhaus.wallpaper.mark.enablehaus.wallpaper.mark.opacityhaus.wallpaper.mark.risehaus.wallpaper.mark.sizehaus.wallpaper.mark.weighthaus.wallpaper.sizehaus.wallpaper.stylehaus.fontshaus.fonts.mono.baseSizehaus.fonts.mono.namehaus.fonts.mono.packagehaus.fonts.mono.packageNamehaus.fonts.mono.sizehaus.accessibilityhaus.accessibility.closeViewScrollWheelTogglehaus.accessibility.closeViewZoomFollowsFocushaus.accessibility.differentiateWithoutColorhaus.accessibility.increaseContrasthaus.accessibility.mouseDriverCursorSizehaus.accessibility.reduceMotionhaus.accessibility.reduceTransparencyhaus.animationshaus.animationshaus.screenshotshaus.screenshots.formathaus.screenshots.includeDatehaus.screenshots.locationhaus.screenshots.shadowhaus.screenshots.thumbnailhaus.soundhaus.sound.alertSoundhaus.sound.alertVolumehaus.sound.startupChimehaus.sound.uiSoundshaus.sound.volumeFeedbackDisplayshaus.displayshaus.displayshaus.displays.<name>.uiScaleDevelopmenthaus.hearthhaus.hearth.editorhaus.hearth.editorNamehaus.hearth.floatBorderhaus.hearth.ghDash.enablehaus.hearth.hijackFileAssociationshaus.hearth.lanes.backendhaus.hearth.obsidianVaultshaus.hearth.rightClickFullscreenhaus.hearth.zellijStartLockedhaus.zenhaus.zen.extensionshaus.zen.extensions.<name>.enablehaus.zen.extensions.<name>.idhaus.zen.extensions.<name>.modehaus.zen.extensions.<name>.slughaus.zen.extensions.<name>.urlhaus.zen.extraPolicieshaus.zen.tabBridge.enablehaus.developerhaus.developer.enablehaus.developer.git.enablehaus.developer.languageshaus.developer.toolbelt.enableWindowshaus.hotCornershaus.hotCorners.bottomLefthaus.hotCorners.bottomRighthaus.hotCorners.topLefthaus.hotCorners.topRighthaus.prowlhaus.prowl.enableBarhaus.menuBarhaus.menuBar.clock.analoghaus.menuBar.clock.formathaus.menuBar.clock.showDatehaus.menuBar.clock.showDayOfWeekhaus.menuBar.clock.showSecondshaus.menuBar.controlCenter.airdrophaus.menuBar.controlCenter.batteryPercentagehaus.menuBar.controlCenter.bluetoothhaus.menuBar.controlCenter.displayBrightnesshaus.menuBar.controlCenter.focushaus.menuBar.controlCenter.nowPlayinghaus.menuBar.controlCenter.soundhaus.sillhaus.sill.aiUsage.providerhaus.sill.battery.hideOverhaus.sill.bottom.enablehaus.sill.bottom.itemshaus.sill.bottom.items.agentshaus.sill.bottom.items.aiUsagehaus.sill.bottom.items.batteryhaus.sill.bottom.items.caffeinatehaus.sill.bottom.items.calendarhaus.sill.bottom.items.clockhaus.sill.bottom.items.cpuhaus.sill.bottom.items.elgatohaus.sill.bottom.items.githubhaus.sill.bottom.items.harvesthaus.sill.bottom.items.hushhaus.sill.bottom.items.mediahaus.sill.bottom.items.memoryhaus.sill.bottom.items.volumehaus.sill.bottom.items.weatherhaus.sill.bottom.items.wifihaus.sill.calendar.horizonhaus.sill.calendar.imminenthaus.sill.calendar.joinHostshaus.sill.calendar.mehaus.sill.calendar.pasthaus.sill.calendar.preciseUnderhaus.sill.calendar.refreshhaus.sill.calendar.upcominghaus.sill.calendar.widthhaus.sill.clock.modehaus.sill.clock.monoFonthaus.sill.elgato.hosthaus.sill.enablehaus.sill.github.refreshhaus.sill.github.sourceshaus.sill.github.sources.*.cihaus.sill.github.sources.*.commandhaus.sill.github.sources.*.iconhaus.sill.github.sources.*.limithaus.sill.github.sources.*.orghaus.sill.github.sources.*.searchhaus.sill.github.sources.*.severityhaus.sill.github.sources.*.titlehaus.sill.itemshaus.sill.items.agentshaus.sill.items.aiUsagehaus.sill.items.batteryhaus.sill.items.caffeinatehaus.sill.items.calendarhaus.sill.items.claudeUsagehaus.sill.items.clockhaus.sill.items.cpuhaus.sill.items.elgatohaus.sill.items.githubhaus.sill.items.harvesthaus.sill.items.mediahaus.sill.items.memoryhaus.sill.items.volumehaus.sill.items.weatherhaus.sill.items.wifihaus.sill.logo.colorhaus.sill.logo.gestureshaus.sill.logo.iconhaus.sill.logo.sizehaus.sill.logo.statushaus.sill.logo.sweephaus.sill.logo.updateCheckhaus.sill.media.artworkTinthaus.sill.media.collapsehaus.sill.media.iconshaus.sill.media.widthhaus.sill.positionLauncherhaus.pouncehaus.pounce.autoQuit.delayhaus.pounce.autoQuit.enablehaus.pounce.autoQuit.excludehaus.pounce.enablehaus.pounce.followSystemAppearancehaus.pounce.itemshaus.pounce.items.<name>.aliashaus.pounce.items.<name>.captionhaus.pounce.items.<name>.hotkeyhaus.pounce.items.<name>.listedhaus.pounce.scalehaus.pounce.signingIdentityhaus.pounce.windowModehaus.pounce.windowSwitcherShelfhaus.perchhaus.perch.enablehaus.perch.followSystemAppearanceFocushaus.hushhaus.hush.enablehaus.hush.hookshaus.hush.slack.enablehaus.hush.slack.snoozehaus.hush.slack.statusEmojihaus.hush.slack.statusTexthaus.hush.slack.tokenCommandAIhaus.aihaus.ai.clientshaus.ai.defaulthaus.ai.enablehaus.ai.instructionshaus.ai.skillText expansionhaus.snippetshaus.snippets.enablehaus.snippets.matcheshaus.snippets.matches.*.replacehaus.snippets.matches.*.triggerSecurityhaus.lockhaus.lock.requirePasswordhaus.lock.requirePasswordDelayhaus.securityhaus.security.firewall.allowSignedhaus.security.firewall.allowSignedApphaus.security.firewall.blockAllIncominghaus.security.firewall.enablehaus.security.firewall.stealthModehaus.collarhaus.collar.enablehaus.collar.passwordlessRebuildhaus.secretshaus.secrets.providerShared surfaceshaus.rosterhaus.rosterhaus.roster.<name>.appIdhaus.roster.<name>.appStoreIdhaus.roster.<name>.brewhaus.roster.<name>.caskhaus.roster.<name>.enablehaus.roster.<name>.floathaus.roster.<name>.installedByhaus.roster.<name>.keyhaus.roster.<name>.labelhaus.roster.<name>.namehaus.roster.<name>.orderhaus.roster.<name>.packagehaus.roster.<name>.packageNamehaus.roster.<name>.scopehaus.roster.<name>.titleRegexhaus.workspaceshaus.workspaceshaus.workspaces.<name>.appshaus.workspaces.<name>.iconhaus.workspaces.<name>.keyhaus.uihaus.ui.scalehaus.keyshaus.keys.leaderhaus.keys.leaderExtrashaus.keys.leaderExtras.*.captionhaus.keys.leaderExtras.*.commandhaus.keys.leaderExtras.*.keyhaus.keys.palettehaus.keys.windowNavhaus.tourhaus.tour.enablehaus.tour.stepshaus.tour.steps.*.detecthaus.tour.steps.*.hintYour machinehaus.githaus.git.emailhaus.git.namehaus.git.orghaus.git.shellAliaseshaus.git.signingKeyhaus.localehaus.locale.hourFormathaus.locale.inputSourceshaus.locale.languagehaus.locale.metrichaus.locale.regionhaus.locale.temperaturehaus.powerhaus.power.computerSleep.batteryhaus.power.computerSleep.chargerhaus.power.diskSleep.batteryhaus.power.diskSleep.chargerhaus.power.displaySleep.batteryhaus.power.displaySleep.chargerhaus.power.lowPowerMode.batteryhaus.power.lowPowerMode.charger