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.
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:
truemodules/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.
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.
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.
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).
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 inmodules/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:
truemodules/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"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"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"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 inmodules/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"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"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:
truemodules/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"
]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:
96modules/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.02modules/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:
0modules/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"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.
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.6modules/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:
14modules/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.0modules/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"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 inmodules/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.55modules/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.06modules/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.3modules/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.055modules/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"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"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:
19modules/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"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-codemodules/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"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:
24modules/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:
truemodules/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:
truemodules/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:
truemodules/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:
truemodules/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.0modules/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.accessibilityDisplayShouldReduceMotion — hausax reads
that exact property, so hausax | jq .reduceMotion is how you check
it landed. Turning it on rewrites the web: mostly for the better,
except on sites whose scroll-reveal animation is what makes the
content visible in the first place, which then never appears at all.
If what you want is a snappier Dock and nothing else,
haus.animations = "fast" is five plain timing keys in two ordinary
domains, moves no accessibility flag, and needs no TCC grant.
null (the default) leaves whatever you have alone — this is a personal setting, so haus never picks a value for you.
REACHABILITY — com.apple.universalaccess is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
com.apple.universalaccess has options here at all — written the
other way, through system.defaults.universalaccess.*, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
haus plan says up front when a rebuild needs the grant, and
haus doctor's Permissions section says whether this app has it.
Example:
truemodules/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:
truemodules/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/closeGOING 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"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"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:
truemodules/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"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:
falsemodules/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:
falsemodules/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 Tinknull (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"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:
50modules/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:
falsemodules/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:
falsemodules/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:
truemodules/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";
};
}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"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"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"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'ssurface0, one step off the terminal's own background: the same relationship the bar's dropdowns wear (popup.background.border_colorin 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'Thaus.theme.accent, the same escape hatchhaus.sill.logo.coloroffers.
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"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:
truemodules/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.)
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'swindow-title-regex, and the lane inholt --jsonall 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.
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"
]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 inmodules/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).
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";
};
}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 inmodules/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}"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.
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"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.
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; }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 inmodules/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:
truemodules/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.
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:
[ ]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 inmodules/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 windownull (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"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 windownull (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"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 windownull (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"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 windownull (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"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 inmodules/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:
falsemodules/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"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"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:
truemodules/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:
falsemodules/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:
falsemodules/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:
truemodules/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:
truemodules/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:
truemodules/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:
truemodules/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:
falsemodules/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:
truemodules/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"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:
80modules/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:
truemodules/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";
}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.
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.
modules/sill/options.nix.
haus.sill.bottom.items.battery
boolean or one of "left", "center", "right" · default false
The battery pill.
Declared inmodules/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.
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.
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 inmodules/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.
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.
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.
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 inmodules/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.
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.
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 inmodules/sill/options.nix.
haus.sill.bottom.items.volume
boolean or one of "left", "center", "right" · default false
Output volume / mute state.
Declared inmodules/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 inmodules/sill/options.nix.
haus.sill.bottom.items.wifi
boolean or one of "left", "center", "right" · default false
The Wi-Fi status pill.
Declared inmodules/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:
12modules/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:
2modules/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"
]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"
]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:
8modules/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:
3modules/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:
60modules/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:
3modules/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:
16modules/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"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:
falsemodules/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"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 inmodules/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:
900modules/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";
}
]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:
truemodules/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'"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:
""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:
5modules/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"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"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"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"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;
}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.
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.
modules/sill/options.nix.
haus.sill.items.battery
boolean · default true
The battery pill.
Declared inmodules/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.
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.
modules/sill/options.nix.
haus.sill.items.claudeUsage
boolean · default false
Deprecated alias for aiUsage.
modules/sill/options.nix.
haus.sill.items.clock
boolean · default true
The clock pill, pinned to the far right.
Declared inmodules/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.
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.
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.
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 inmodules/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.
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 inmodules/sill/options.nix.
haus.sill.items.volume
boolean · default false
Output volume / mute state.
Declared inmodules/sill/options.nix.
haus.sill.items.weather
boolean · default true
The weather pill and its click-to-open forecast popover.
Declared inmodules/sill/options.nix.
haus.sill.items.wifi
boolean · default true
The Wi-Fi status pill.
Declared inmodules/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"modules/sill/options.nix.
haus.sill.logo.gestures
boolean · default true
What the logo pill does when clicked:
| gesture | what it opens |
|---|---|
| left click | the haus menu — System Settings, Activity Monitor, Lock Screen, Nix Config, Haus Settings, Rebuild System, Reload SketchyBar |
| ⌘ left click | haus rebuild, straight into a floating terminal |
| right click | the full pounce palette (⌘Space), which is what a bare click on this pill used to do |
All three are drawn by pounce, so all three need
haus.pounce.enable (which the nebelhaus desktop turns on). With pounce
off they are silent no-ops and this option is the switch that says so out
loud — turn it off and the pill stops responding to clicks entirely,
rather than looking like an affordance that does nothing.
The menu's rows are not reimplemented here: each one runs the palette command of the same name, so fixing one fixes both places. That is the whole reason the popup dropdown this replaces is gone — it was a second copy of five of these rows, and (having never been openable at all) a second copy nobody could check.
Declared inmodules/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:
| glyph | codepoint | what it is |
|---|---|---|
| U+F015 | nf-fa-home — solid house (the default) |
| U+F46D | nf-oct-home — outlined house at icon weight |
| U+EB06 | nf-cod-home — the same, slightly rounder |
⌂ | U+2302 | the hausfold mark, hairline |
| U+F302 | nf-fa-apple — the logo this pill replaced |
| U+F313 | nf-linux-nixos — the snowflake |
There is deliberately no way to point this at an image file. SketchyBar
draws a background.image left-anchored, at a scale you have to
hand-tune per asset, and applies no tint to it — so a picture here can
follow neither haus.theme.accent nor the state colours below, and
cannot sweep on hover. haus drew this pill as a PNG for a while and
every one of those was a real limitation of it.
Example:
"⌂"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:
25modules/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:
| colour | meaning |
|---|---|
| accent | everything haus runs is up |
yellow | a newer haus is pinned upstream (needs haus.sill.logo.updateCheck) |
red | something haus runs is enabled but not running |
Red is the one that matters. It is the same check haus doctor opens
with — nix-daemon, plus each of AeroSpace / SketchyBar / pounce whose
launchd job exists on this machine — and its whole point is that a
wedged agent is otherwise invisible: the bar keeps drawing the last
frame it painted, so a dead SketchyBar and a quiet one look identical.
All of it is local, costs four 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 inmodules/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 inmodules/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:
truemodules/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:
truemodules/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:
truemodules/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" = "";
}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:
16modules/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"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:
5modules/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 inmodules/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"
]modules/pounce/options.nix.
haus.pounce.enable
boolean · default false
The pounce command palette daemon (⌘Space) + the palette commands haus ships.
Declared inmodules/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 inmodules/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";
};
}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"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"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"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.)
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.4modules/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)"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 inmodules/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 inmodules/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 inmodules/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 inmodules/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.
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" ]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 inmodules/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 inmodules/hush/options.nix.
haus.hush.slack.statusEmoji
string · default ":no_bell:"
Slack status emoji while hushed.
Declared inmodules/hush/options.nix.
haus.hush.slack.statusText
string · default "heads down"
Slack status text while hushed.
Declared inmodules/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"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"
]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"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.
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…
''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.
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 inmodules/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"; }
]modules/snippets/options.nix.
haus.snippets.matches.*.replace
string · no default
What it expands to.
Example:
"ada@example.com"modules/snippets/options.nix.
haus.snippets.matches.*.trigger
string · no default
What you type.
Example:
"@@"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:
truemodules/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:
5modules/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:
truemodules/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:
truemodules/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:
falsemodules/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:
truemodules/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:
truemodules/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 inmodules/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.
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"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; };
}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"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:
497799835modules/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"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"modules/options.nix.
haus.roster.<name>.enable
boolean · default true
Whether this app participates in the shared launcher roster.
Declared inmodules/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.
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"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"modules/options.nix.
haus.roster.<name>.label
null or string · default null
Cheatsheet caption for the leader key. null uses name.
Example:
"Slack"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"modules/options.nix.
haus.roster.<name>.order
signed integer · default 1000
Roster order; lower values appear first. Ties are sorted by app id.
Declared inmodules/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.orbstackmodules/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"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, asudoworkflow, or an activation script actually needs. (It is about REACH, not about the package needing elevated privileges to install:darwin-rebuildruns under sudo either way.)
Ignored when package is null — Homebrew has no such split.
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$"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" ]; };
}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"
]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:"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"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.35modules/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"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";
}
]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"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'"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"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"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"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.
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 ↵";
}
]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"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 ↵"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"modules/hearth/options.nix.
haus.git.name
string · default ""
Git user.name for commits (hearth wires it into home-manager).
Example:
"Ada Lovelace"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"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
}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"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"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 --allTHIS 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"
]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"
]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:
truemodules/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"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"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:
10modules/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:
10modules/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:
10modules/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:
10modules/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:
10modules/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:
10modules/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:
truemodules/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:
falsemodules/den/options.nix.