# haus.* options (/docs/haus/reference/options)



{/* GENERATED FILE — do not edit by hand.

     Rendered from haus's own module system by scripts/gen-options.mjs.
     To change an option's description, edit its declaration in haus and
     regenerate:

         node scripts/gen-options.mjs --haus /path/to/haus

     CI re-renders this and fails if it differs, so a hand edit here is
     guaranteed to be reverted. */}

These are the `haus.*` options you set in your host file at
`~/.config/nix/hosts/<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.bar` (its own bar) *and*
`haus.menuBar` (macOS's).

Apply changes with `haus rebuild`. Each option lists its **type**, its
**default** and, where it fits on the line, an example under its name, and
links to the file that declares it. A long description opens on its first
paragraph and keeps the rest behind **More detail**; nothing is cut, and
search finds what is inside it.

{/* The scope hook for this page's layout. Every rule that turns an option
    heading into a ruled row hangs off `.prose:has(.hf-options)` in
    src/app/global.css, so nothing here reaches an ordinary docs page. The
    div draws nothing. */}

<div className="hf-options" />

A few also carry a line about what a **shared desktop** may do with them.
*Host-only* means [a desktop](/docs/haus/desktops/creating) may not set it, and
the reason why is stated beside it. *Desktop-safe per key* means the option
takes keys nobody declared, so a named rule decides which of them a desktop may
write; that rule is stated beside it too. Anything unmarked is plain
desktop-safe. This is the same classification `haus.lib.checkDesktop` enforces
before a desktop is evaluated.

## Apps [#apps]

The apps a finished machine has: the curated picks, the packs that switch a whole set on in one line, App Store policy, and what a rebuild does to anything you installed by hand. The list they all land in is `haus.roster`, a shared surface below.

### haus.appStore [#hausappstore]

Whether a rebuild may install the roster's `appStoreId` entries. Off by default: it reaches the network and acts on your Apple Account, and it can never be complete — `mas` cannot sign in, and cannot buy a paid app. Sign in once in App Store.app first, or every fetch waits on a sign-in sheet a rebuild cannot answer.

#### `haus.appStore.install` [#haus-appstore-install]

`boolean` · default `false`

Install roster entries that set `appStoreId` from the Mac App
Store during activation, skipping any already installed.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.appStore.install</span>
  </summary>

  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.

  Sign in once in App Store.app before you turn this on. A Mac
  that is signed out is asked for its Apple Account by a sheet on
  SCREEN, which a rebuild cannot fill in, so each fetch carries a
  clock (`haus.appStore.timeout`): it gives up, says why, and
  moves on rather than holding the rebuild open.

  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 no password is ever asked for.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.appStore.timeout` [#haus-appstore-timeout]

`positive integer, meaning >0` · default `3600` · e.g. `900`

How long one App Store fetch may run before activation stops
waiting on it, in seconds.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.appStore.timeout</span>
  </summary>

  This is the bound on a question a rebuild cannot answer, not a
  slow-network allowance. `mas` has no way to ask whether this Mac
  is signed in, and a Mac that is signed out meets a fetch with a
  "Sign in to download from the App Store" sheet drawn on SCREEN,
  which an unattended rebuild can neither fill in nor outlive. A
  clock is the only bound on offer, so it has to sit above the
  longest fetch you would ever sit through — and it is the same
  clock either way, which is why it is yours to set.

  An hour by default, because it has to clear the biggest thing
  you might reasonably declare: Xcode is around 15 GB, and a
  deadline that a normal Xcode download cannot meet is not a
  safety net, it is a rebuild that gives up every time. Lower it
  if everything you fetch is small and you would rather hear about
  a stall sooner.

  The first fetch to run the clock out skips the App Store entries
  after it for that rebuild, since whatever stopped one is likely
  to stop the next — one deadline per rebuild rather than one per
  app. Fix the cause and rebuild again.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

### haus.apps [#hausapps]

The apps haus picks for you and the saved collections you can switch on in one line — 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.

<div className="hf-optindex">
  [`cursor.enable`](#haus-apps-cursor-enable) [`packs.writing.enable`](#haus-apps-packs-writing-enable) [`vscode.enable`](#haus-apps-vscode-enable) [`zed.enable`](#haus-apps-zed-enable)
</div>

#### `haus.apps.cursor.enable` [#haus-apps-cursor-enable]

`boolean` · default `false`

Install Cursor as the roster entry `cursor` (cask `cursor`), beside
whatever `haus.terminal.editorName` installs — name it THERE to make
it the editor. Already have it installed some other way?
`haus.homebrew.adopt` (on by default) adopts it instead of
installing a second copy.

<small>
  Declared in 

  [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)

  .
</small>

#### `haus.apps.packs.writing.enable` [#haus-apps-packs-writing-enable]

`boolean` · default `false` · e.g. `true`

Install the **writing** collection: Obsidian, Zotero, Anki and calibre —
a Mac that reads and writes rather than compiles.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.apps.packs.writing.enable</span>
  </summary>

  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:

  ```nix
  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.
</details>

<small>
  Declared in 

  [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)

  .
</small>

#### `haus.apps.vscode.enable` [#haus-apps-vscode-enable]

`boolean` · default `false`

Install Visual Studio Code as the roster entry `vscode` (cask
`visual-studio-code`), beside whatever `haus.terminal.editorName`
installs — name it THERE to make it the editor. Already have it
installed some other way? `haus.homebrew.adopt` (on by default)
adopts it instead of installing a second copy.

<small>
  Declared in 

  [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)

  .
</small>

#### `haus.apps.zed.enable` [#haus-apps-zed-enable]

`boolean` · default `false`

Install Zed as the roster entry `zed` (cask `zed`). Redundant on a
machine whose `haus.terminal.editorName` is `zed` (the default),
which writes the same entry; this is for keeping Zed on a machine
that edits in something else. Already have it installed some other
way? `haus.homebrew.adopt` (on by default) adopts it instead of
installing a second copy.

<small>
  Declared in 

  [`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)

  .
</small>

### haus.homebrew [#haushomebrew]

How rebuilds treat Homebrew packages you did not declare.

<div className="hf-optindex">
  [`adopt`](#haus-homebrew-adopt) [`autoUpdate`](#haus-homebrew-autoupdate) [`cleanup`](#haus-homebrew-cleanup) [`upgrade`](#haus-homebrew-upgrade)
</div>

#### `haus.homebrew.adopt` [#haus-homebrew-adopt]

`boolean` · default `true`

Whether a cask haus declares that is already sitting in
/Applications — installed by hand, the App Store, or anything
other than Homebrew — gets adopted into Homebrew's bookkeeping
instead of failing activation with "there is already an App at
…". Nothing about the app itself changes; only whether Homebrew
considers itself the owner.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.homebrew.adopt</span>
  </summary>

  On by default: without it, the roster's whole "declare an app,
  haus makes sure it's there" promise breaks the moment that app
  happens to already be installed some other way — which is common
  for editors, browsers and other apps most people bring with them.

  Current Homebrew (`bundle/cask.rb`) adopts every such cask
  unconditionally on its own, with no supported flag left to opt
  out — `brew bundle install --adopt` was removed, and the only
  alternative, `--force`, overwrites instead of refusing. Setting
  this to `false` is a no-op until Homebrew grows a real way back
  to "fail loudly on conflict" for `brew bundle`.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.homebrew.autoUpdate` [#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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.homebrew.cleanup` [#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).

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.homebrew.upgrade` [#haus-homebrew-upgrade]

`boolean` · default `false`

Upgrade outdated Homebrew packages on every rebuild. Off by default
for the same reproducibility reason as autoUpdate.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

## Appearance [#appearance]

How the machine looks: the palette and its accent, the wallpaper, the fonts, and the macOS surfaces that follow them — motion, screenshots, sound and the accessibility keys. The interface scale every room reads is `haus.ui`, a shared surface below.

### haus.appearance [#hausappearance]

The Appearance room's own profiles — named answers to whole-machine questions, 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. `reduceMotion` stops the motion haus itself draws — the bar's hover sweeps, the pointer following focus, the pull back off an emptied workspace — and asks for macOS's own Reduce Motion alongside it. Both set every value as a default you can still pin by hand. Neither is symmetric: turning one off returns the leaves haus writes at both settings and leaves the ones it writes only while the profile is on standing — `largePrint`'s own description names the three that stay and the lines that put them back.

#### `haus.appearance.largePrint` [#haus-appearance-largeprint]

`boolean` · default `false` · e.g. `true`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.appearance.largePrint</span>
  </summary>

  It sets four options, each as a default, so pinning any of them by hand
  still wins. docs/model.md has the priority ladder and the one asymmetry:
  a desktop that names one of the four beats this profile even when your
  host is what switched the profile on.

  ```text
  haus.ui.scale = 1.4
    the terminal font 19 → 27 pt, Dock icons 48 → 67, and the rest of
    the list on that option
  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 is skipped where the grant is missing
  haus.displays.main.uiScale = "larger-text"
    one step of the screen's scaled resolution toward larger text, and
    the only line here that reaches apps haus has never heard of, because
    it changes what a point means
  ```

  Turning it off is not the mirror image, and the arithmetic is worth
  having straight before you try it. `haus.ui.scale` and
  `haus.theme.contrast` go back to their own values and everything they
  render follows: the terminal drops 27 → 19 pt, the command palette returns
  to 1.0, the normal-contrast colours come back. The other two do not go
  back at all — they land on `null`, which haus reads as *leave whatever you
  have alone*. And `ui.scale` leaves one thing behind on the way down: the
  Dock's icon size, which it writes only while the scale is not 1.0. So two
  of the four return, and three settings stay on the Mac — Increase Contrast
  on, the Dock at 67, the display still a step down the ladder.

  That is what `false` means here rather than an oversight. The line the
  three fall on is whether haus writes that setting at BOTH values of this
  option: it writes the terminal's size, the palette and Finder's sidebar
  rows either way, so those follow, and it writes the other three only while
  the profile is on, so `false` writes nothing for them. Not writing a key
  is not the same as writing the old value back, and leaving a setting alone
  is the right answer for a personal one — nothing in the evaluation can
  tell your Mac apart from one that never asked for large print.

  So say it, in the same host, and one rebuild puts the three back:

  ```nix
  haus.accessibility.increaseContrast = false;
  haus.displays.main.uiScale          = "default";
  system.defaults.dock.tilesize       = 48;
  ```

  The third line is nix-darwin's own key rather than a haus one because haus
  has no leaf that says 48 — adding one would snap back every Dock anybody
  ever sized by hand.
  (`defaults delete com.apple.dock tilesize && killall Dock` is the same
  trip by hand, and leaves the key absent rather than pinned at 48.) The
  first line is FDA-gated exactly as setting it `true` was, so a Mac that has
  lost the grant since you turned this on gets the other two and a warning.

  Delete them again after the rebuild unless you meant to keep them, because
  they are ASSERTIONS rather than undos:
  `system.defaults.dock.tilesize` is a plain host value and outranks the
  `mkDefault` `haus.ui.scale` writes, so leaving it in means a later
  `ui.scale = 1.4` moves everything except your Dock — and a named
  `haus.displays` entry puts that panel under management, driven to its
  default rung at every activation rather than left alone. Only
  `increaseContrast = false` is the same kind of statement as the `true` it
  replaces.

  They also land you on the STOCK Mac rather than on your own old values,
  and `haus capture` is a smaller help here than it looks. Its snapshots
  restore through `defaults import`, which MERGES: a key the snapshot HOLDS
  goes back to what it held, and one that was unset when you captured is not
  removed again. Measured on haus `ef6e808d`: a `tilesize` absent at capture
  and written to 67 is still 67 after `haus revert-settings` reports the
  domain restored, while one captured at 48 does come back. The Dock and the
  contrast key are both normally unset on a Mac that never asked for them,
  which is the case it cannot help with — and if you DID pick your own Dock
  size, plain `haus capture` covers it, while your own contrast needs
  `haus capture com.apple.universalaccess` by name (and Full Disk Access at
  restore time, or it is skipped). The display is outside all of this: its
  scaled resolution is in no preference domain and no snapshot, so write
  that one down yourself.

  Each of those four says where it stops, and haus.ui.scale is the one to
  read: the shelf and the menu bar's height follow neither lever. One stop
  belongs here, because it is the lever people expect and it does not
  exist: macOS's own text-size setting. `universalaccess`'s
  FontSizeCategory stores a value and notifies nobody, so apps never
  re-read it (docs/macos-settings.md has the measurement), and the fourth
  line above is how a large-print machine reaches apps outside haus at all.
  To key it on a specific monitor rather than `main`, name that monitor by
  UUID in your host; `hausdisp list` prints them.

  Two things it leaves to you. A more legible font family, because 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:

  ```nix
  haus.fonts.mono.packageName = "nerd-fonts.atkynson-mono";
  haus.fonts.mono.name        = "AtkynsonMono Nerd Font";
  ```

  And light mode, if it reads better for you: `haus.theme.flavor = "latte"`.
</details>

<small>
  Declared in 

  [`modules/appearance/options.nix`](https://github.com/hausfold/haus/blob/main/modules/appearance/options.nix)

  .
</small>

#### `haus.appearance.reduceMotion` [#haus-appearance-reducemotion]

`boolean` · default `false` · e.g. `true`

Stop the things haus itself animates from animating. One switch, for a
machine whose user is vestibular-sensitive, motion-sick, or simply done
with movement in the corner of their eye.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.appearance.reduceMotion</span>
  </summary>

  It exists because macOS's own switches do not reach the motion haus draws.
  `haus.animations` retunes the Dock's timings and
  `haus.accessibility.reduceMotion` flips Apple's flag; the bar's sweeps and
  the tiler's automatic moves are the layer's own and run happily with both
  of those set.

  What it stops, each as a default, so any single one goes back by name:
  this option `true` with `haus.bar.logo.sweep = true` keeps the sweep and
  drops the rest.

  ```text
  haus.bar.logo.sweep        six family accents turning through the mark
                             whenever the pointer crosses it
  haus.bar.media.marquee     a long track title sweeping past on hover
  haus.bar.calendar.marquee  the same, for a long meeting title
  haus.windows.mouseFollowsFocus
                             a pointer teleporting across the desk is
                             movement you did not make
  haus.windows.gravity       the automatic pull back to a populated
                             workspace when a ⌘Q empties this one, a
                             whole screen changing under you unasked
  ```

  Nothing loses information: a title too long for its pill is clipped rather
  than swept, and both dropdowns carry it in full.

  It also asks for macOS's own "Reduce motion"
  (`haus.accessibility.reduceMotion`), again as a default, because a machine
  that quietened its own five surfaces and left Spaces sliding would have
  answered the question halfway. Read that option before you take it: it is
  the single flag every browser reads as `prefers-reduced-motion: reduce`,
  so it rewrites the web as well, and
  `haus.accessibility.reduceMotion = false` in your host keeps haus's half
  without the web one. haus's half needs no permission, so it still applies
  on a machine where Apple's FDA-gated flag is skipped.

  That last line is also the way back. Turning this option off re-renders
  haus's own five and they stop, but Apple's flag is a preference macOS now
  holds rather than a file haus redraws, so it stays on until you say
  `haus.accessibility.reduceMotion = false` yourself — the same asymmetry
  `largePrint` has, one leaf instead of three.
</details>

<small>
  Declared in 

  [`modules/appearance/options.nix`](https://github.com/hausfold/haus/blob/main/modules/appearance/options.nix)

  .
</small>

### haus.theme [#haustheme]

Colour: the palette's flavour and contrast, the accent every themed tool spends, and whether macOS's own Light/Dark follows it.

<div className="hf-optindex">
  [`accent`](#haus-theme-accent) [`contrast`](#haus-theme-contrast) [`flavor`](#haus-theme-flavor) [`ports.enable`](#haus-theme-ports-enable) [`systemAppearance`](#haus-theme-systemappearance)
</div>

#### `haus.theme.accent` [#haus-theme-accent]

`one of "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `"mauve"` · e.g. `"sapphire"`

The accent colour, by Catppuccin name. The Nebelung palette is a
grey-tinted Catppuccin, so the fourteen names are the same in both
flavors and the hue you get follows haus.theme.flavor.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.theme.accent</span>
  </summary>

  What follows it: lazygit, fzf, yazi, glow (the Markdown in yazi's
  preview pane and at your own prompt alike), Zen's own UI, the generated
  desktop picture (the bloom behind the mark in "minimal", the whole sweep in
  "bold"), the bar's far-left logo pill, the shelf, and any roster app
  whose Nebelung port ships a per-accent matrix (zed, gh-dash, mpv) once
  haus.theme.ports places it. The `accent-reach` flake check fingerprints
  every one of those under three accents, so a surface cannot start or
  stop following the accent without someone deciding it should.

  The shelf is the one surface handed the name rather than a hex, so the
  ember under the notch and a pinned tile wear this accent in whichever
  half of its dark/light pair macOS is showing. Its own default is mark
  green.

  Three limits. A per-accent port names its 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. (Zed as THE
  editor is the exception — the Development room places its port under
  one fixed name and writes the key, so the accent just arrives.) Single-file
  dotfiles that bake the palette at their own theme slot (ghostty,
  starship, tmux, bat, …) keep their built-in colour, and the base palette
  stays the same Nebelung grey either way, so only the accent hue moves.
  And `haus.bar.logo` is the only pill this reaches; every other colour on
  the bar is a fixed palette key, unless `haus.bar.logo.color` names one
  of its own.

  Zen's own UI is not the web. The Nebelung userContent haus places styles
  `about:` pages only, so github.com and youtube.com need
  `haus.zen.userStyles`: name the sites and haus compiles their userstyles
  with this accent into that same userContent.css, which a rebuild and a
  Zen restart pick up with nothing to import by hand. No per-site toggle
  and nothing self-updates, so adding a site takes a rebuild.
</details>

<small>
  Declared in 

  [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)

  .
</small>

#### `haus.theme.contrast` [#haus-theme-contrast]

`one of "normal", "high"` · default `"normal"` · e.g. `"high"`

How far the interface separates from its background.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.theme.contrast</span>
  </summary>

  "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, glow, starship, lazygit, the
  bar, the launcher and the shelf (at runtime, via
  \~/.config/\{pounce,perch}/themes/ — and unlike `flavor`, contrast reaches
  both on BOTH halves of their light/dark pair), Zen, and Obsidian once
  `haus.terminal.obsidianVaults` names a vault. 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.
</details>

<small>
  Declared in 

  [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)

  .
</small>

#### `haus.theme.flavor` [#haus-theme-flavor]

`one of "mocha", "latte"` · default `"mocha"` · e.g. `"latte"`

Light or dark. "mocha" is the dark half of Nebelung, "latte" the light
one, and latte is a real source palette rather than the dark one
inverted: Catppuccin Latte put through Nebelung's "strip the blue out"
rule, so it keeps the same warm-grey ramp and the same calmed accents
the other way up. It reaches 7.0:1 for body text before you touch
`contrast`. Together with `contrast` that is four palettes, and
nebelung's CI measures each one rather than eyeballing it.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.theme.flavor</span>
  </summary>

  What follows it: every tool haus themes itself. Ghostty, bat, delta,
  lsd, yazi, glow, fzf, starship, lazygit, zsh-syntax-highlighting,
  opencode, the bar and Zen always, and three more that wait on something
  else: the editor when `haus.terminal.editorName` picks zed (the
  default) or helix (Nebelung has a port for those two and none for the
  rest), gh-dash under `haus.terminal.ghDash.enable`, and Obsidian once
  `haus.terminal.obsidianVaults` names a vault. Each one is re-rendered
  for the flavor rather than recoloured in place.

  Three things it does not reach:

  * the launcher and the shelf, which read their palette at runtime and
    follow macOS Light/Dark instead. haus installs every rendered
    variant into \~/.config/\{pounce,perch}/themes/ and writes the
    dark/light pair at your `contrast`. Set
    haus.launcher.followSystemAppearance or
    haus.shelf.followSystemAppearance false to pin one of them to this
    flavor like everything else.
  * macOS's own Light/Dark appearance, until you set
    haus.theme.systemAppearance = "flavor". Left alone haus touches it
    in neither direction, so latte on a dark macOS looks half-done and
    that half is yours.
  * the desktop picture, unless it is "minimal", which follows the
    flavor in every part: field, mark, glow and debug band. See
    haus.wallpaper.style for what the others do instead.
</details>

<small>
  Declared in 

  [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)

  .
</small>

#### `haus.theme.ports.enable` [#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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.theme.ports.enable</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)

  .
</small>

#### `haus.theme.systemAppearance` [#haus-theme-systemappearance]

`one of "unmanaged", "flavor", "light", "dark"` · default `"unmanaged"` · e.g. `"flavor"`

Whether haus also sets macOS's own Light/Dark appearance, the one in
System Settings ▸ Appearance that paints Finder, the menu bar and every
native app haus cannot reach.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.theme.systemAppearance</span>
  </summary>

  ```text
  unmanaged  (default) leave it alone, in both directions. A managed
             default would silently revert an appearance you picked in
             System Settings on the next rebuild.
  flavor     follow haus.theme.flavor: latte sets Light, mocha Dark.
             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.
  ```

  haus flips it through System Events at each home-manager activation and
  confirms the result with `hausax`, which reads AppKit's effective
  appearance. It never writes `NSGlobalDomain.AppleInterfaceStyle`: that
  key is inert in both directions on macOS 26 and 27 and mirrors the appearance
  back at you, so a plist read calls an inert write applied.
  docs/macos-settings.md has the measurement.

  Two things leave the appearance where it was. 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 with everything else. And
  System Settings ▸ Appearance ▸ Auto keeps switching polarity on its own
  schedule, which haus does not fight, so there this option holds only
  until the next scheduled switch. Pick Light or Dark in that panel if
  you want it to stick.

  Setting "flavor" settles the launcher and the shelf too:
  haus.\{launcher,shelf}.followSystemAppearance hand polarity to macOS,
  and macOS's polarity is now haus's.
</details>

<small>
  Declared in 

  [`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)

  .
</small>

### haus.wallpaper [#hauswallpaper]

The desktop behind everything. `minimal` is generated on this machine — a flat field at whatever depth you pick out of the palette, the haus mark ⌂ at its centre, a bloom in your accent, and enough grain that none of it bands. The other looks are the hand-made Nebelung ones.

<div className="hf-optindex">
  [`background`](#haus-wallpaper-background) [`debug.enable`](#haus-wallpaper-debug-enable) [`debug.inputs`](#haus-wallpaper-debug-inputs) [`debug.inset`](#haus-wallpaper-debug-inset) [`debug.size`](#haus-wallpaper-debug-size) [`depth`](#haus-wallpaper-depth) [`glow.color`](#haus-wallpaper-glow-color) [`glow.enable`](#haus-wallpaper-glow-enable) [`glow.spread`](#haus-wallpaper-glow-spread) [`glow.strength`](#haus-wallpaper-glow-strength) [`grain`](#haus-wallpaper-grain) [`mark.color`](#haus-wallpaper-mark-color) [`mark.enable`](#haus-wallpaper-mark-enable) [`mark.opacity`](#haus-wallpaper-mark-opacity) [`mark.rise`](#haus-wallpaper-mark-rise) [`mark.size`](#haus-wallpaper-mark-size) [`mark.weight`](#haus-wallpaper-mark-weight) [`size`](#haus-wallpaper-size) [`style`](#haus-wallpaper-style)
</div>

#### `haus.wallpaper.background` [#haus-wallpaper-background]

`null or string matching the pattern #[0-9a-fA-F]{6}` · default `null` · e.g. `"#0b0b0e"`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.debug.enable` [#haus-wallpaper-debug-enable]

`boolean` · default `false` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.debug.inputs` [#haus-wallpaper-debug-inputs]

`list of string` · default `[ "self" "nebelung" "pounce" "perch" "scruff" ]`

Which flake inputs the band names, in the order it prints them. `self`
is haus itself and prints as `haus`; every other entry is an input
name out of haus's own flake, and one that isn't there is skipped
rather than failing the build.

The default is the family chain, which is the one thing a rev is worth
knowing on a desktop: it's what `bench status` calls the lock edges, and
the answer to "is this machine running the branch I just merged".

Example:

```nix
[
  "self"
  "nixpkgs"
]
```

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.debug.inset` [#haus-wallpaper-debug-inset]

`null or (unsigned integer, meaning >=0)` · default `null` · e.g. `96`

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

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.debug.size` [#haus-wallpaper-debug-size]

`integer or floating point number between 0.002 and 0.1 (both inclusive)` · default `0.011` · e.g. `0.02`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.depth` [#haus-wallpaper-depth]

`integer between 0 and 5 (both inclusive)` · default `1` · e.g. `0`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.wallpaper.depth</span>
  </summary>

  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:

  ```text
  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.
</details>

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.glow.color` [#haus-wallpaper-glow-color]

`null or string matching the pattern #[0-9a-fA-F]{6}` · default `null` · e.g. `"#8db4f3"`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.glow.enable` [#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`.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.glow.spread` [#haus-wallpaper-glow-spread]

`integer or floating point number between 0.2 and 4.0 (both inclusive)` · default `1.15` · e.g. `0.6`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.glow.strength` [#haus-wallpaper-glow-strength]

`integer between 0 and 100 (both inclusive)` · default `3` · e.g. `14`

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

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.grain` [#haus-wallpaper-grain]

`integer or floating point number between 0.0 and 0.1 (both inclusive)` · default `0.01` · e.g. `0.0`

Film grain over the whole field, as a fraction of full scale — and the
reason the glow doesn't band.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.wallpaper.grain</span>
  </summary>

  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:

  ```text
  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
  ```
</details>

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.mark.color` [#haus-wallpaper-mark-color]

`one of "muted", "ink", "accent", "spectrum"` · default `"spectrum"` · e.g. `"muted"`

What the mark is drawn in.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.wallpaper.mark.color</span>
  </summary>

  ```text
  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, scruff, perch, trill, pounce,
             hacker — 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.
</details>

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.mark.enable` [#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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.mark.opacity` [#haus-wallpaper-mark-opacity]

`integer or floating point number between 0.0 and 1.0 (both inclusive)` · default `1.0` · e.g. `0.55`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.mark.rise` [#haus-wallpaper-mark-rise]

`integer or floating point number between -0.5 and 0.5 (both inclusive)` · default `0.0` · e.g. `0.06`

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.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.mark.size` [#haus-wallpaper-mark-size]

`integer or floating point number between 0.01 and 0.9 (both inclusive)` · default `0.1` · e.g. `0.3`

The mark's height, as a fraction of the picture's SHORT edge — so it
keeps its proportion whatever `size` and whatever display.

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.mark.weight` [#haus-wallpaper-mark-weight]

`integer or floating point number between 0.005 and 0.25 (both inclusive)` · default `0.09` · e.g. `0.055`

Stroke width, as a fraction of the mark's own height.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.wallpaper.mark.weight</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.size` [#haus-wallpaper-size]

`string matching the pattern [0-9]+x[0-9]+` · default `"3456x2234"` · e.g. `"3024x1964"`

The pixel size `minimal` is rendered at, `WIDTHxHEIGHT`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.wallpaper.size</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

#### `haus.wallpaper.style` [#haus-wallpaper-style]

`one of "none", "minimal", "orbits", "constellation", "flow", "bold"` · default `"none"` · e.g. `"minimal"`

Which desktop this machine wears, set at each home-manager activation
(osascript, every desktop on the current Space).

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.wallpaper.style</span>
  </summary>

  ```text
  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 hacker 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 hacker
  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.
</details>

<small>
  Declared in 

  [`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)

  .
</small>

### haus.fonts [#hausfonts]

The machine's type. One mono family drives the terminal AND the bar — the bar stopped keeping a hardcoded font of its own, though it keeps its own tuned sizes. One proportional family drives everything else haus draws: the clock pill when it opts out of mono, and the text in pounce, perch and trill.

<div className="hf-optindex">
  [`mono.baseSize`](#haus-fonts-mono-basesize) [`mono.name`](#haus-fonts-mono-name) [`mono.package`](#haus-fonts-mono-package) [`mono.packageName`](#haus-fonts-mono-packagename) [`mono.size`](#haus-fonts-mono-size) [`sans.name`](#haus-fonts-sans-name) [`sans.package`](#haus-fonts-sans-package) [`sans.packageName`](#haus-fonts-sans-packagename)
</div>

#### `haus.fonts.mono.baseSize` [#haus-fonts-mono-basesize]

`positive integer, meaning >0` · default `13` · e.g. `19`

The terminal-font baseline, before `haus.ui.scale` multiplies it.
The neutral room uses 13pt; hacker 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`.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.mono.name` [#haus-fonts-mono-name]

`string` · default `"JetBrainsMono Nerd Font Mono"` · e.g. `"Berkeley Mono"`

haus's type family, as Ghostty's `font-family` names it.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.fonts.mono.name</span>
  </summary>

  It reaches the terminal AND the menu bar: every pill label and icon
  the bar 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 the bar 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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.mono.package` [#haus-fonts-mono-package]

`null or package` · default `null` · e.g. `pkgs.nerd-fonts.fira-code`

**Host-only.** A shared desktop may not set it; only your host file can. It takes a `pkgs` value, and desktop data is evaluated with no module arguments to take one from. The `…Name` leaf beside it is the desktop-safe half of the pair.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.mono.packageName` [#haus-fonts-mono-packagename]

`null or string` · default `null` · e.g. `"nerd-fonts.fira-code"`

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 can change the font FAMILY and not
just its size — reaching `pkgs` is precisely what that format forbids,
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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.mono.size` [#haus-fonts-mono-size]

`positive integer, meaning >0` · default `fonts.mono.baseSize, scaled by haus.ui.scale and rounded` · e.g. `24`

Terminal font size in points. The single most useful knob for a
larger-text machine, since it moves everything haus actually
lives in.

hacker's 19pt baseline exists for a reason worth knowing: the Ghostty window is
tiled to a fixed pixel height by windows, and sizes that don't divide
that height evenly used to leave a gap along its bottom edge, under
the multiplexer's status bar. That's since been fixed properly
(`window-padding-balance`, which splits the leftover pixels evenly
instead of dumping them all at the bottom), so any size is safe now —
19 is simply the tuned starting point.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.sans.name` [#haus-fonts-sans-name]

`string` · default `".AppleSystemUIFont"` · e.g. `"Atkinson Hyperlegible"`

The proportional family this layer's own surfaces draw in: the clock
pill's date and time (when `haus.bar.clock.monoFont` is false), and
the text in pounce, perch and trill — the launcher and its rows, the
notch shelf, every notification banner and all three settings
windows.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.fonts.sans.name</span>
  </summary>

  The default is macOS's own system UI font, whose zero has no dot and
  is easier to tell from an 8 at a glance. That legibility is the
  entire reason the clock has an opt-out, so the default is also the
  answer for almost everybody.

  Two things this does NOT do, both worth knowing before you set it.
  It does not change the system UI font — macOS has no supported knob
  for that, so menus, Finder and Safari are unmoved. And it does not
  touch monospaced text anywhere: a keycap, a timestamp, a source slug
  and a code preview stay mono, because a column that shifts is harder
  to read rather than easier.

  Name a family the machine has — macOS ships plenty, and `haus.roster`
  installs more — or one `package` / `packageName` puts there. A family
  that isn't there falls back to the system font **silently**: there is
  no tofu to see, and every surface goes on looking exactly as it did,
  so a misspelling reads as "the option does nothing". Check the
  spelling against Font Book if nothing moves.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.sans.package` [#haus-fonts-sans-package]

`null or package` · default `null` · e.g. `pkgs.atkinson-hyperlegible`

**Host-only.** A shared desktop may not set it; only your host file can. It takes a `pkgs` value, and desktop data is evaluated with no module arguments to take one from. The `…Name` leaf beside it is the desktop-safe half of the pair.

The package providing `name`. null (the default) installs nothing,
which is correct for the default family: `.AppleSystemUIFont` is
macOS's own and is on every Mac.

Set this whenever you set `name` to something the machine doesn't
already have, or the family simply won't exist and every surface
falls back to the system font — which looks exactly like the setting
not working. Unlike `fonts.mono`, haus does NOT warn about the
combination: naming a proportional family the Mac already has is the
ordinary case, so the warning would fire on correct configurations.

A shared desktop can't set this one — it needs `pkgs`, and a
data-only desktop has no arguments. Use `packageName` there.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.fonts.sans.packageName` [#haus-fonts-sans-packagename]

`null or string` · default `null` · e.g. `"atkinson-hyperlegible"`

The same thing as `package`, NAMED rather than evaluated: an
attribute path into nixpkgs, so "atkinson-hyperlegible" means
`pkgs.atkinson-hyperlegible`.

This exists so a data-only desktop can change the proportional family
and not just name one it hopes is installed — reaching `pkgs` is
precisely what that format forbids.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.accessibility [#hausaccessibility]

macOS accessibility keys haus can actually apply. These write to a TCC-protected domain, so they take effect only when the app you run the rebuild from holds Full Disk Access — otherwise haus warns and moves on.

<div className="hf-optindex">
  [`closeViewScrollWheelToggle`](#haus-accessibility-closeviewscrollwheeltoggle) [`closeViewZoomFollowsFocus`](#haus-accessibility-closeviewzoomfollowsfocus) [`differentiateWithoutColor`](#haus-accessibility-differentiatewithoutcolor) [`increaseContrast`](#haus-accessibility-increasecontrast) [`mouseDriverCursorSize`](#haus-accessibility-mousedrivercursorsize) [`reduceMotion`](#haus-accessibility-reducemotion) [`reduceTransparency`](#haus-accessibility-reducetransparency)
</div>

#### `haus.accessibility.closeViewScrollWheelToggle` [#haus-accessibility-closeviewscrollwheeltoggle]

`null or boolean` · default `null` · e.g. `true`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.closeViewScrollWheelToggle</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.accessibility.closeViewZoomFollowsFocus` [#haus-accessibility-closeviewzoomfollowsfocus]

`null or boolean` · default `null` · e.g. `true`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.closeViewZoomFollowsFocus</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.accessibility.differentiateWithoutColor` [#haus-accessibility-differentiatewithoutcolor]

`null or boolean` · default `null` · e.g. `true`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.differentiateWithoutColor</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.accessibility.increaseContrast` [#haus-accessibility-increasecontrast]

`null or boolean` · default `null` · e.g. `true`

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 haus theme: the theme
restyles the tools haus colours, this reaches everything else.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.increaseContrast</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.accessibility.mouseDriverCursorSize` [#haus-accessibility-mousedrivercursorsize]

`null or integer or floating point number between 1.0 and 4.0 (both inclusive)` · default `null` · e.g. `4.0`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.mouseDriverCursorSize</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.accessibility.reduceMotion` [#haus-accessibility-reducemotion]

`null or boolean` · default `null` · e.g. `true`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.reduceMotion</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.accessibility.reduceTransparency` [#haus-accessibility-reducetransparency]

`null or boolean` · default `null` · e.g. `true`

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

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.accessibility.reduceTransparency</span>
  </summary>

  Worth knowing if you run the bar: bar 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.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.animations [#hausanimations]

How much motion macOS spends on its own Dock and windows: the slide, the launch bounce, minimise, Mission Control, window open/close. Unset by default like the rest of this block — `"fast"` opts in, and going back only stops writing rather than restoring. Deliberately not the Accessibility "Reduce motion" switch, which every browser also reads as `prefers-reduced-motion`.

#### `haus.animations` [#haus-animations]

`one of "fast", "system"` · default `"system"` · e.g. `"fast"`

How much motion macOS spends on its own Dock and windows — how long
three animations run, and two it plays at all.

`"system"` (the default) writes NOTHING — not the macOS values, nothing
at all — so whatever your Dock does today, it keeps doing. Same policy
as `haus.hotCorners`: haus doesn't overwrite a setting you didn't
ask it about.

`"fast"` writes five keys, all `mkDefault`, so any one of them can be
overridden by name in your host file:

```
  com.apple.dock  autohide-time-modifier         0.15   Dock slide
  com.apple.dock  expose-animation-duration      0.1    Mission Control
  com.apple.dock  launchanim                     false  the bouncing icon
  com.apple.dock  mineffect                      scale  minimise (not genie)
  NSGlobalDomain  NSAutomaticWindowAnimationsEnabled  false  window open/close
```

GOING BACK IS NOT AUTOMATIC, which is the one thing about this group
that can surprise you and the reason it isn't on by default. Setting
`"system"` again means STOP WRITING, not RESTORE: a `defaults` write is
sticky and macOS keeps no memory of what was there before, so once
you've rebuilt on `"fast"`, the five keys keep haus's numbers.
Undoing it means naming the values you want back in your host file
(they're `mkDefault`, so a plain value wins), or a `defaults delete`.
Worth knowing before you try `"fast"` on a Dock you tuned by hand.

WHY THIS ISN'T "REDUCE MOTION". macOS's accessibility switch of that
name (`com.apple.universalaccess reduceMotion`) would cover all of this
and more — but it is also the single flag every browser maps to the
`prefers-reduced-motion: reduce` CSS media query, via
`NSWorkspace.accessibilityDisplayShouldReduceMotion`. Turning it on
rewrites the web: mostly for the better, except on sites whose
scroll-reveal animation is what sets the content visible in the first
place, which then never appears at all. These five keys are in two
entirely different domains and move no accessibility flag — `hausax`
reads that exact `NSWorkspace` property, so `hausax | jq .reduceMotion`
stays `false` with this set to `"fast"` (on a machine that hasn't also
set `haus.accessibility.reduceMotion`, which is the option that DOES move
it) — that's the whole reason this
group exists as five curated keys instead of one switch. If you DO want
the accessibility switch, it is `haus.accessibility.reduceMotion`: a
separate option, in a TCC-protected domain, with that blast radius spelled
out on it. Setting both is coherent and neither implies the other.

WHEN YOU'LL FEEL IT. The four Dock keys are live the moment activation
finishes — nix-darwin restarts the Dock whenever anything in its domain
is written, and haus always writes `autohide`. The NSGlobalDomain
one is read by each app AT LAUNCH, so apps you already have open keep
animating their windows until you relaunch them; `activateSettings`
can't reach back into a running `NSApplication`.

These are timings, not a state haus can prove from a plist — unlike
the `haus.accessibility` keys, there's no oracle for "did the Dock
slide faster". They're felt, not measured. The one measurable claim
here is the negative one above.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.screenshots [#hausscreenshots]

Where ⇧⌘4 puts its files, in what format, and whether it draws a window shadow or a preview thumbnail. Unset by default, so macOS's own choices stand.

<div className="hf-optindex">
  [`format`](#haus-screenshots-format) [`includeDate`](#haus-screenshots-includedate) [`location`](#haus-screenshots-location) [`shadow`](#haus-screenshots-shadow) [`thumbnail`](#haus-screenshots-thumbnail)
</div>

#### `haus.screenshots.format` [#haus-screenshots-format]

`null or one of "png", "jpg", "pdf", "tiff", "heic", "gif"` · default `null` · e.g. `"png"`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.screenshots.includeDate` [#haus-screenshots-includedate]

`null or boolean` · default `null` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.screenshots.location` [#haus-screenshots-location]

`null or string` · default `null` · e.g. `"~/Pictures/Screenshots"`

**Host-only.** A shared desktop may not set it; only your host file can. It names a path on this disk, so it is a fact about one filesystem rather than an opinion a shared desktop can hold about every machine.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.screenshots.shadow` [#haus-screenshots-shadow]

`null or boolean` · default `null` · e.g. `false`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.screenshots.thumbnail` [#haus-screenshots-thumbnail]

`null or boolean` · default `null` · e.g. `false`

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.

One room answers this for you: the shelf turns the thumbnail off at
`mkDefault` while `haus.shelf.watchScreenshots` is on, because a
capture macOS is still holding cannot reach the shelf. Naming this
option in your host outranks that and puts the thumbnail back.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.sound [#haussound]

Alert volume and sound, interface sound effects, and the boot chime. Volume is 0–100 the way the slider reads it — macOS stores a curve, and haus does the conversion.

<div className="hf-optindex">
  [`alertSound`](#haus-sound-alertsound) [`alertVolume`](#haus-sound-alertvolume) [`startupChime`](#haus-sound-startupchime) [`uiSounds`](#haus-sound-uisounds) [`volumeFeedback`](#haus-sound-volumefeedback)
</div>

#### `haus.sound.alertSound` [#haus-sound-alertsound]

`null or one of "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink"` · default `null` · e.g. `"Submarine"`

Which sound the alert beep plays, by name:

```
  Basso  Blow  Bottle  Frog  Funk  Glass  Hero  Morse  Ping  Pop  Purr  Sosumi  Submarine  Tink
```

null (the default) leaves macOS's own choice alone.

An enum rather than a path on purpose. macOS stores an absolute path
here and validates nothing, and a path that doesn't resolve does not
fall back to the default beep — it goes SILENT (measured by ear,
2026-08-08), while the plist still reads like a working setting.
haus builds the path from the name and skips the write with a warning
if that file is missing, so a macOS release retiring a sound can't
quietly mute you.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.sound.alertVolume` [#haus-sound-alertvolume]

`null or integer between 0 and 100 (both inclusive)` · default `null` · e.g. `50`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.sound.startupChime` [#haus-sound-startupchime]

`null or boolean` · default `null` · e.g. `false`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.sound.uiSounds` [#haus-sound-uisounds]

`null or boolean` · default `null` · e.g. `false`

Play user-interface sound effects — the Trash whoosh, the screenshot
shutter, the Mail whoosh. null (the default) leaves macOS's own
choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.sound.volumeFeedback` [#haus-sound-volumefeedback]

`null or boolean` · default `null` · e.g. `true`

Play a sound when the volume keys change the volume. null (the
default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

## Displays [#displays]

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

### haus.displays [#hausdisplays]

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

<div className="hf-optindex">
  [`haus.displays`](#haus-displays) [`<name>.arrangement`](#haus-displays-name-arrangement) [`<name>.arrangement.align`](#haus-displays-name-arrangement-align) [`<name>.arrangement.of`](#haus-displays-name-arrangement-of) [`<name>.arrangement.side`](#haus-displays-name-arrangement-side) [`<name>.uiScale`](#haus-displays-name-uiscale)
</div>

#### `haus.displays` [#haus-displays]

`attribute set of (submodule)` · default `{ }`

**Desktop-safe per key** (`display-selectors`). Only the `internal` and `main` selectors: a display UUID names one physical panel on one desk, which is a fact about a machine rather than a taste a desktop can share.

Per-display settings, keyed by which screen you mean:

```text
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
```

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.displays</span>
  </summary>

  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 (haus's
  `docs/macos-settings.md` records the sweep). So
  `haus.ui.scale` and `haus.fonts` make *haus's own tools* bigger, and
  this makes the *Mac* bigger.
</details>

Example:

```nix
{
  "37D8832A-2D66-02CA-B9F7-8F30A301B230" = {
    uiScale = "more-space";
  };
  internal = {
    uiScale = "larger-text";
  };
}
```

<small>
  Declared in 

  [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)

  .
</small>

#### `haus.displays.<name>.arrangement` [#haus-displays-name-arrangement]

`null or (submodule)` · default `null`

Where this display sits relative to another, as a relation rather
than a pixel origin — macOS's own arrangement editor, said in a way
a desktop can write. The origin is COMPUTED at each activation from
both panels' current point sizes, the same derived-not-tabulated
taste as `uiScale`, so the same relation follows a scaling change
automatically.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.displays.<name>.arrangement</span>
  </summary>

  Order is load-bearing: origins are in points, and a `uiScale` change
  moves every origin, so this room applies every `uiScale` entry
  before any `arrangement` entry and a profile never computes against
  a stale point size. Where two displays each carry an `arrangement`,
  the one named in `of` is placed first, so a desk can be built in
  relations; a cycle is refused at eval.

  `main` is part of what this asserts, not a separate setting:
  macOS makes a display main by putting it at the origin (0, 0), and
  a relation can put one there. When it does, that display becomes
  main. `hausdisp arrange … --dry-run` prints the origin a relation
  resolves to before you write it into the host file, and `hausdisp
  list` prints the `at x,y` to compare it against.

  An arrangement is about a desk, so it applies only while BOTH
  displays are attached: an absent one is skipped with a note, like
  an absent `uiScale`, and re-asserted at the next activation after
  the dock returns. Between activations macOS keeps its own
  remembered arrangement.
</details>

Example:

```nix
{
  align = "top";
  of = "internal";
  side = "right-of";
}
```

<small>
  Declared in 

  [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)

  .
</small>

#### `haus.displays.<name>.arrangement.align` [#haus-displays-name-arrangement-align]

`null or one of "top", "center", "bottom", "left", "right"` · default `null` · e.g. `"top"`

Along the other axis: `top`/`center`/`bottom` for
`right-of`/`left-of`, `left`/`center`/`right` for
`above`/`below`. null (the default) pins the shared edge —
the top edge for a horizontal side, the left edge for a
vertical one.

<small>
  Declared in 

  [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)

  .
</small>

#### `haus.displays.<name>.arrangement.of` [#haus-displays-name-arrangement-of]

`string` · no default · e.g. `"internal"`

Which display this one is placed beside — `internal`, `main`,
or a persistent display UUID, the same selectors
`haus.displays` is keyed by. May not name this display itself.

<small>
  Declared in 

  [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)

  .
</small>

#### `haus.displays.<name>.arrangement.side` [#haus-displays-name-arrangement-side]

`one of "right-of", "left-of", "above", "below"` · no default · e.g. `"right-of"`

Which side of the other display this one sits on.

<small>
  Declared in 

  [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)

  .
</small>

#### `haus.displays.<name>.uiScale` [#haus-displays-name-uiscale]

`null or one of "more-space", "default", "slightly-larger-text", "larger-text", "largest-text"` · default `null` · e.g. `"larger-text"`

The scaled resolution, as an intent rather than a pixel count —
the positions System Settings ▸ Displays offers, named:

```text
more-space            the largest resolution the panel offers
                      (smallest UI)
default               the panel's own default mode
slightly-larger-text  between the default and larger-text
larger-text           between the default and the smallest
                      resolution
largest-text          the smallest resolution the panel offers
                      (biggest UI)
```

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.displays.<name>.uiScale</span>
  </summary>

  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 · 1352x878 ·
  1147x745 · 1024x665 — one name per position, matching that panel's
  five.

  `slightly-larger-text` earns its place on a big external panel, where
  the ladder is long and the jumps are not evenly spaced: a 27" 5K
  reports nine rungs, so `larger-text` lands four of them below the
  default (2560x1440 → 1440x810, a wall of pixels) while
  `slightly-larger-text` lands on 1920x1080. On a short ladder, where
  `larger-text` is already the very next rung down, the two names agree
  rather than inventing a rung that isn't there.

  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.
</details>

<small>
  Declared in 

  [`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)

  .
</small>

## Development [#development]

The terminal stack — terminal, shell, multiplexer, editor — plus the browser, the CLI toolbelt, Git tooling and language runtimes. Your commit identity itself is a fact about you rather than this room's, and stays in your host. The terminal lives here because a terminal with no tools in it is not a separate thing anyone wants.

### haus.terminal [#hausterminal]

The shell and terminal experience.

<div className="hf-optindex">
  [`editor`](#haus-terminal-editor) [`editorName`](#haus-terminal-editorname) [`floatBorder`](#haus-terminal-floatborder) [`floatOnTop`](#haus-terminal-floatontop) [`ghDash.enable`](#haus-terminal-ghdash-enable) [`hijackFileAssociations`](#haus-terminal-hijackfileassociations) [`obsidianVaults`](#haus-terminal-obsidianvaults) [`restoreWindows`](#haus-terminal-restorewindows)
</div>

#### `haus.terminal.editor` [#haus-terminal-editor]

`string` · default `haus.terminal.editorName's command — zed --wait for zed` · e.g. `"subl -w"`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

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. A terminal editor opens the
target in a new terminal WINDOW running this command. A GUI editor's
CLI (`zed`, `code`, `cursor`, `subl`, …) is handed the project root
and the file directly, no window in between, with the blocking flag
git needs (`-w`, `--wait`) dropped for that one call.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.terminal.editor</span>
  </summary>

  It defaults to the command for `haus.terminal.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
  — and the editor `editorName` names is still installed beside it, as
  the fallback (Zed by default); name `nano` there if you would rather
  not carry one.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.editorName` [#haus-terminal-editorname]

`one of "cursor", "helix", "nano", "neovim", "vim", "vscode", "zed"` · default `"zed"` · e.g. `"helix"`

Which editor this room installs. `zed` (the default) arrives as a
roster cask, painted from the palette, with its Nix, TOML, Swift,
HTML, Dockerfile and Make extensions installed on first launch;
`vscode` and `cursor` are casks too, in their own colours. `helix` is
the terminal editor haus themes; `neovim`, `vim` and `nano` are
installed as-is, with no Nebelung theme — Nebelung has a port for zed
and helix and not for the rest.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.terminal.editorName</span>
  </summary>

  Setting this also moves `haus.terminal.editor`, since that defaults to
  whatever the chosen editor answers to on PATH (`zed --wait`, `code -w`,
  `hx`, `nvim`, …). Choosing here is the whole gesture: the editor is
  installed AND every "open in an editor" action follows it — in a new
  terminal window for a terminal editor, in the app itself for a GUI one.

  A desktop may set this. To point haus at an editor it does not
  install — something from your own host file — leave this alone and
  set `haus.terminal.editor` instead.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.floatBorder` [#haus-terminal-floatborder]

`one of "accent", "grey", "off", "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `"accent"` · e.g. `"grey"`

The outline drawn around every floating terminal `float-term.sh` spawns:
the ⌘Y yazi peek panel, the bar's agent peek, and the palette's
Rebuild System / Install App / Settings 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.terminal.floatBorder</span>
  </summary>

  * `accent` (the default) — `haus.theme.accent`, so a summoned window
    announces itself and the whole desktop keeps one accent.
  * `grey` — Nebelung's `surface0`, one step off the terminal's own
    background: the same relationship the bar's dropdowns wear
    (`popup.background.border_color` in modules/bar), for an edge that
    defines the window without drawing the eye.
  * `off` — no outline; the look before this option existed. It also keeps
    floatring out of the closure entirely, so nothing is compiled for it.
  * any Nebelung accent name (`lavender`, `sapphire`, …) — one colour for
    these popups that ISN'T `haus.theme.accent`, the same escape hatch
    `haus.bar.logo.color` offers.

  2pt, following the window's own corner curve. Drawn by a tiny overlay
  window (modules/terminal/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 terminal.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/haus/term/float-term.sh ring "$(pgrep -x ghostty | head -1)" '#cba6f7'`
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.floatOnTop` [#haus-terminal-floatontop]

`boolean` · default `true` · e.g. `false`

Whether every floating terminal `float-term.sh` spawns — the ⌘Y yazi
peek panel, ⌘G's gh-dash, the bar's agent peek, the palette's Rebuild
System / Install App / Settings windows — stays above the tiled windows
behind it, whatever you click next.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.terminal.floatOnTop</span>
  </summary>

  `floating` in AeroSpace is a LAYOUT, not a stacking order: it keeps a
  window out of the tiling tree and says nothing about what is drawn on
  top of what. So without this, clicking any tiled window buries the popup
  you just summoned, and the only way back to it is Mission Control.

  Off restores that: popups open in front and then sink like ordinary
  windows.

  It works by setting the window's LEVEL — the same mechanism that makes a
  panel float above every app — which nothing outside the owning process
  can do. haus can therefore only pin the popups it spawns ITSELF; a
  floating FaceTime or System Settings window is beyond it. That is a
  macOS limit rather than a missing option: see
  modules/terminal/floatpin.swift for the measurements, including why
  raising the window instead does not work.

  The ask is an Apple event to the popup's own Ghostty process, so macOS
  gates it behind an Automation grant for whatever SPAWNED the popup, not
  for haus: Pounce for the chords and palette windows, and SketchyBar for
  the bar's agent peek, which summons its own. Both are one card in `haus
  permissions`. Decline either and those popups still open, still float
  and still take focus without waiting — they just sink like ordinary
  windows again.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.ghDash.enable` [#haus-terminal-ghdash-enable]

`boolean` · default `false` · e.g. `true`

Whether to enable the themed gh-dash GitHub dashboard and its ⌘G
near-fullscreen floating window.

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

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.hijackFileAssociations` [#haus-terminal-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.terminal.editor — a
GUI editor directly, a terminal editor in a new terminal window. One
exception: a file inside an Obsidian vault (any vault the app has
registered) opens in Obsidian, so a clicked link to a note lands in
the note. 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 com.hausfold.editoropen public.unix-executable all`.)

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.obsidianVaults` [#haus-terminal-obsidianvaults]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It names a path on this disk, so it is a fact about one filesystem rather than an opinion a shared desktop can hold about every machine.

Home-relative paths to existing Obsidian vaults that should use the
Nebelung theme. On each activation, Terminal 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.terminal.obsidianVaults</span>
  </summary>

  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.

  A vault whose appearance.json iCloud has evicted, or that holds JSON
  Terminal cannot parse, gets the theme files but keeps its own
  appearance settings, with a warning naming the vault. Activation
  never fails over a vault.
</details>

Example:

```nix
[
  "Library/Mobile Documents/iCloud~md~obsidian/Documents/notes"
]
```

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.terminal.restoreWindows` [#haus-terminal-restorewindows]

`boolean` · default `true` · e.g. `false`

Put your terminal windows back when Ghostty starts.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.terminal.restoreWindows</span>
  </summary>

  Closing a window does not end its shell — every window is a `zmx`
  session that outlives it, so a ⌘W, a ⌘Q or a crash leaves the shells
  (and any agents) running with nothing looking at them. On, the FIRST
  window of a Ghostty reopens one window per parked session, each attached
  to the session it belongs to, with lanes landing back on their own
  `T/<repo>` pages. Off, that first window is just a new shell and the
  parked sessions wait — the palette's **Restore Terminal Windows** does
  the same thing on demand either way, as does the `agents` pill, ⌘F's ⏎
  and the **Lanes** picker for one session at a time.

  Only the FIRST window restores, never a later one: ⌘N and ⌘⇧N always
  open a new shell in the directory you asked for, which is the only thing
  they promise. A shell you are finished with should be ended (⌃D, or
  `exit`) rather than closed, or it is a window that comes back.

  "First" means nothing is attached anywhere on the machine, which is a
  shade broader than "first window of this Ghostty": with a tiler, a lane
  runs as its own Ghostty instance, so quitting the main one while a lane
  window is still open leaves something attached and the automatic restore
  stays quiet. The palette row is the answer there, and is the answer
  whenever the automatic moment has passed.

  Needs `haus.ai.clients` — not for the lanes, but because `zmx` itself
  rides that switch, and with no zmx there are no sessions to park.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

### haus.zen [#hauszen]

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

<div className="hf-optindex">
  [`extensions`](#haus-zen-extensions) [`extensions.<name>.enable`](#haus-zen-extensions-name-enable) [`extensions.<name>.id`](#haus-zen-extensions-name-id) [`extensions.<name>.mode`](#haus-zen-extensions-name-mode) [`extensions.<name>.slug`](#haus-zen-extensions-name-slug) [`extensions.<name>.url`](#haus-zen-extensions-name-url) [`extraPolicies`](#haus-zen-extrapolicies) [`tabBridge.enable`](#haus-zen-tabbridge-enable) [`userStyles`](#haus-zen-userstyles)
</div>

#### `haus.zen.extensions` [#haus-zen-extensions]

`attribute set of (submodule)` · default `{ }`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

Browser extensions to deploy into Zen, by a stable id of your choosing.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.zen.extensions</span>
  </summary>

  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.

  Every entry needs an `id` — the key Firefox's policy engine matches
  on, unguessable and unreachable from the add-on's name. haus fills it in
  for the add-ons whose id it has read off a live profile
  (stylus),
  so those need only be named. Everything else brings its own — see the
  `id` option for where to read one off.

  This is deployment, not theming. haus themes Zen's own UI through
  `haus.theme.accent` and real websites through `haus.zen.userStyles`,
  which compiles the Nebelung userstyles straight into the profile's
  userContent.css. Stylus used to be the second half of that story — haus
  stamped your accent, flavor and contrast into a bundle and nudged you to
  import it — and that half was retired on 2026-08-20. Naming `stylus`
  here still deploys the extension; it just arrives unthemed now, so keep
  the sites you care about in `haus.zen.userStyles` instead.
</details>

Example:

```nix
{
  ublock-origin = {
    id = "uBlock0@raymondhill.net";
    slug = "ublock-origin";
  };
}
```

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.extensions.<name>.enable` [#haus-zen-extensions-name-enable]

`boolean` · default `true`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

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

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.extensions.<name>.id` [#haus-zen-extensions-name-id]

`null or string` · default `null` · e.g. `"{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}"`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

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.

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.extensions.<name>.mode` [#haus-zen-extensions-name-mode]

`one of "force_installed", "normal_installed", "allowed", "blocked"` · default `"force_installed"`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

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.

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.extensions.<name>.slug` [#haus-zen-extensions-name-slug]

`null or string` · default `null` · e.g. `"styl-us"`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

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.

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.extensions.<name>.url` [#haus-zen-extensions-name-url]

`string` · default `""`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

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.

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.extraPolicies` [#haus-zen-extrapolicies]

`attribute set` · default `{ }` · e.g. `{ DisableTelemetry = true; }`

**Host-only.** A shared desktop may not set it; only your host file can. It installs browser extensions, or writes raw enterprise policy into a file haus owns as root: code reaching your browser through what is supposed to be readable data.

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.zen.extraPolicies</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.tabBridge.enable` [#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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.zen.tabBridge.enable</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.zen.userStyles` [#haus-zen-userstyles]

`list of string` · default `[ ]`

Nebelung userstyles to compile into Zen's `userContent.css`, by slug —
the palette on real websites, with **no extension and no import click**.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.zen.userStyles</span>
  </summary>

  These are the 134 styles nebelung ships for the Stylus extension,
  taken the other way round. They are LESS source, which is why the
  accent could only ever reach the web through an import click: the
  extension compiled them in the browser. haus compiles the ones you name
  here at build time instead, stamping the same three axes
  (`haus.theme.accent`, and the flavor on both its light and dark vars),
  and appends the result to the stylesheet it already drops into every
  Zen profile. Nothing to re-import and no state to carry to the
  next machine — but a **restart of Zen** is what applies it: Firefox
  reads `userContent.css` once at startup, so a rebuild alone leaves an
  open browser on the previous colours.

  A slug is the style's own name in nebelung's bundle — `github`,
  `youtube`, `reddit`, `hacker-news`. Naming one that doesn't exist fails
  the build and lists every slug there is, so a typo costs a rebuild
  rather than a silently unthemed site.

  **Keep the list short.** A user stylesheet is parsed and applied to
  every document, and these are big: github and youtube together are
  \~320 KB, the whole set is 7 MB of CSS on every page load to theme sites
  you never open. This is for the handful you actually read.

  Code blocks are themed too, including on the couple of dozen styles
  that reach for them through a remote `@import url(...)` — mdn,
  wikipedia, stack-overflow and the nix docs among them. An `@import`
  inside an `@-moz-document` block is invalid CSS wherever it points, so
  haus vendors the four files those 29 styles share and pastes each one
  in where its `@import` was. A fifth URL appearing upstream fails the
  build naming it, rather than shipping a style whose code blocks are
  quietly stock.

  Every declaration is compiled to `!important`, and that is load-bearing
  rather than heavy-handed: a user stylesheet's normal declarations rank
  BELOW the page's own in the cascade, so an unstamped sheet matches the
  site and then loses every property to it. Most of these styles theme by
  redefining the site's own custom properties without `!important` —
  which is free for an extension, since it injects author-origin CSS —
  so without the stamp they render nothing. It was measured that way: this
  option shipped twice before anyone loaded a page instead of checking
  that the file was in the profile.

  The stamp is skipped exactly where `!important` would be invalid and
  the declaration would therefore be dropped: `@keyframes`, descriptor
  blocks like `@font-face`, and at-statements.

  This is now the only web-theming path haus ships. Until 2026-08-20 it
  also stamped an importable bundle for the Stylus extension; what that
  click bought — per-site toggles, styles that update themselves, adding
  one without a rebuild — is what a compiled sheet gives up, and none of
  it was being used. `haus.zen.extensions.stylus` still deploys the
  extension, unthemed, but keep a given site in one place or the other:
  they do not tie, and a user sheet's
  `!important` outranks every author sheet, so this one wins and the
  extension's copy of that site would be doing nothing.

  **Gecko only, and permanently so.** `@-moz-document` in a user sheet is
  what makes this possible; Chromium removed user stylesheets in Chrome 33
  and the Blink equivalent would be a self-built extension. Zen is where
  haus points it because Zen is the browser haus themes — the compiled
  sheet itself is engine-generic, so a second Gecko browser would only
  need its profile directory added.
</details>

Example:

```nix
[
  "github"
  "youtube"
  "reddit"
]
```

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

### haus.portless [#hausportless]

Named .localhost URLs for dev servers, with real HTTPS: `https://myapp.localhost` instead of `http://localhost:3000`. One proxy on :443 owns the machine's ports, which is what stops N agent lanes of one repo fighting over the same one.

<div className="hf-optindex">
  [`enable`](#haus-portless-enable) [`https`](#haus-portless-https) [`lanes.enable`](#haus-portless-lanes-enable) [`port`](#haus-portless-port) [`tlds`](#haus-portless-tlds) [`trustCA`](#haus-portless-trustca)
</div>

#### `haus.portless.enable` [#haus-portless-enable]

`boolean` · default `false`

Give every local dev server a stable, named URL instead of a port
number: `https://myapp.localhost` rather than `http://localhost:3000`,
with real HTTPS and no browser warning. A reverse proxy on :443 routes
each name to a port it assigned itself, so two projects that both
default to 3000 stop fighting, a restarted server keeps the tab you had
open, and cookies and localStorage stop leaking between apps that used
to share an origin.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.portless.enable</span>
  </summary>

  The reason it is in haus at all is agent lanes. `scruff` puts N agents in
  N worktrees of the SAME repo, so N copies of `npm run dev` want the
  same port — the second one dies, or quietly takes 3001 and every
  hardcoded URL now points at the wrong lane. portless already knows
  about worktrees: inside one it prefixes the branch, so each lane gets
  its own hostname without anybody choosing a number. `lanes` below is
  what makes that name the lane's, rather than the branch's.

  Off by default: it runs a root daemon on :443 and puts a local
  certificate authority in your system trust store. The CA is a card in
  `haus permissions` rather than something a rebuild does behind your
  back — see `haus.portless.trustCA`.
</details>

<small>
  Declared in 

  [`modules/portless/options.nix`](https://github.com/hausfold/haus/blob/main/modules/portless/options.nix)

  .
</small>

#### `haus.portless.https` [#haus-portless-https]

`boolean` · default `true`

Serve HTTPS with HTTP/2. On means portless mints a per-hostname
certificate from its own local CA, which is the half that needs the
trust-store card. Off serves plain HTTP and needs no CA at all — the
right setting if you want the naming without the certificate.

<small>
  Declared in 

  [`modules/portless/options.nix`](https://github.com/hausfold/haus/blob/main/modules/portless/options.nix)

  .
</small>

#### `haus.portless.lanes.enable` [#haus-portless-lanes-enable]

`boolean` · default `config.haus.ai.enable`

In a `scruff` lane, register the lane's dev server under
`<lane>.<repo>.localhost` — `wiggly-crane.haus.localhost` — rather
than the name portless infers on its own.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.portless.lanes.enable</span>
  </summary>

  It infers a good one already: inside a git worktree it prefixes the
  branch, so a lane lands on `<branch>.<project>.localhost` with no help
  from us. The gap is cosmetic and entirely scruff's fault — scruff's
  branches are `worktree-<lane>` and portless splits a branch on `/`,
  so the prefix comes out as the whole `worktree-wiggly-crane` rather
  than the lane name you actually call it. This registers the shorter
  name alongside; both work.

  Temporary by design: vercel-labs/portless#398 adds `--prefix` and
  `PORTLESS_PREFIX`, which does the same job one level down. When it
  lands, the shim goes and a lane simply exports the variable.

  Follows `haus.ai.enable`, since that is what puts lanes on the
  machine — turning portless on for its own sake never drags a lane
  shim onto a desktop that has no lanes. Setting it true anyway is a
  warning rather than a refusal: the shim already falls back to plain
  `portless run` outside a worktree, so the worst case is a command
  that behaves exactly like the one underneath it.
</details>

<small>
  Declared in 

  [`modules/portless/options.nix`](https://github.com/hausfold/haus/blob/main/modules/portless/options.nix)

  .
</small>

#### `haus.portless.port` [#haus-portless-port]

`16 bit unsigned integer; between 0 and 65535 (both inclusive)` · default `443`

The port the proxy listens on. 443 is what makes the URLs plain
(`https://myapp.localhost`, no `:port` suffix) and is why the daemon
runs as root. Move it above 1024 and the daemon drops to a user agent —
the URLs then carry the port, which costs most of the point.

<small>
  Declared in 

  [`modules/portless/options.nix`](https://github.com/hausfold/haus/blob/main/modules/portless/options.nix)

  .
</small>

#### `haus.portless.tlds` [#haus-portless-tlds]

`list of string` · default `[ "localhost" ]`

The suffixes routes are served under, first one primary. `.localhost`
is the one that needs no DNS at all — browsers resolve it to 127.0.0.1
by rule, not by lookup. Anything else has to resolve some other way,
which on this machine means `portless hosts sync`.

Example:

```nix
[
  "localhost"
  "dev.example.com"
]
```

<small>
  Declared in 

  [`modules/portless/options.nix`](https://github.com/hausfold/haus/blob/main/modules/portless/options.nix)

  .
</small>

#### `haus.portless.trustCA` [#haus-portless-trustca]

`boolean` · default `true`

Whether to put a card in `haus permissions` for the one-time
`portless trust` — adding portless' local CA to the system trust store,
which is what stops the browser warning on every `.localhost` name.

A CARD and not an activation step, deliberately. A rebuild runs as root
and could write the trust store without asking; a certificate authority
your browser will believe for anything is not a thing a machine should
install while you are looking the other way. Set this false if you would
rather run `portless trust` yourself, or are happy clicking through the
warning.

<small>
  Declared in 

  [`modules/portless/options.nix`](https://github.com/hausfold/haus/blob/main/modules/portless/options.nix)

  .
</small>

### haus.developer [#hausdeveloper]

The developer pack: the CLI toolbelt, Git tooling and language runtimes. Coding agents left this pack on 2026-08-13 and are their own room now (`haus.ai.*`). Off is a hacker machine for someone who never opens a terminal by choice.

<div className="hf-optindex">
  [`enable`](#haus-developer-enable) [`git.enable`](#haus-developer-git-enable) [`languages`](#haus-developer-languages) [`toolbelt.enable`](#haus-developer-toolbelt-enable)
</div>

#### `haus.developer.enable` [#haus-developer-enable]

`boolean` · default `false` · e.g. `true`

The Development room: the CLI toolbelt, Git tooling and language
runtimes. The neutral catalogue leaves it off; hacker selects it
in its desktop.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.developer.enable</span>
  </summary>

  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 haus 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:

  ```nix
  haus.developer.enable = true;
  haus.developer.git.enable = false;  # …but leave Git tooling out
  ```
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.developer.git.enable` [#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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.developer.languages` [#haus-developer-languages]

`list of value "node" (singular enum)` · default `[ ]` · e.g. `[ ]`

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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.developer.toolbelt.enable` [#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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

### haus.github [#hausgithub]

This machine's GitHub webhook endpoint: the tunnel, the receiver, and the hooks it wants to exist. Rooms that watch GitHub read its signal and poll on a long backstop instead of a short one. haus never writes to GitHub — it holds no token, and `haus doctor` prints the `gh` command that closes a gap rather than running it.

<div className="hf-optindex">
  [`backstop`](#haus-github-backstop) [`coverageRefresh`](#haus-github-coveragerefresh) [`enable`](#haus-github-enable) [`forwardTo`](#haus-github-forwardto) [`hooks`](#haus-github-hooks) [`hooks.*.events`](#haus-github-hooks-events) [`hooks.*.scope`](#haus-github-hooks-scope) [`port`](#haus-github-port) [`secretCommand`](#haus-github-secretcommand) [`tunnel.credentialsFile`](#haus-github-tunnel-credentialsfile) [`tunnel.enable`](#haus-github-tunnel-enable) [`tunnel.hostname`](#haus-github-tunnel-hostname) [`tunnel.id`](#haus-github-tunnel-id)
</div>

#### `haus.github.backstop` [#haus-github-backstop]

`integer between 60 and 3600 (both inclusive)` · default `300` · e.g. `900`

How long a covered surface may go without asking GitHub anyway, in
seconds.

Push shortens a poll; it never removes one. Nothing distinguishes "no
deliveries because nothing happened" from "no deliveries because the
tunnel died" — GitHub sends no heartbeat — so every consumer keeps a
slow poll under the bridge and this is it. Raise it if you trust the
tunnel and want the quiet; lower it toward the un-bridged interval if a
stale readout would cost you something.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.coverageRefresh` [#haus-github-coveragerefresh]

`integer between 300 and 86400 (both inclusive)` · default `3600` · e.g. `21600`

How often, in seconds, to re-ask GitHub what the declared hooks look
like — are they active, what events are they subscribed to, did the last
delivery land.

Slow on purpose. It is the answer to "may I stop polling", which changes
on a human cadence (someone edits a hook) rather than a machine one, and
it costs an authenticated API call per declared hook. Consumers never
make this call themselves; they read its cached answer.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.enable` [#haus-github-enable]

`boolean` · default `false`

Receive GitHub webhook deliveries on this machine.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.github.enable</span>
  </summary>

  GitHub → a Cloudflare tunnel → a loopback receiver here, which proves
  the delivery's signature, writes down that it happened, forwards the raw
  bytes to anything in `forwardTo`, and wakes whichever rooms asked to
  hear about it. Nothing on GitHub changes: there is no token anywhere in
  this room.

  What it buys is latency and quiet. The surfaces that watch GitHub — the
  bar's octocat pill, the agent statusline's PR column, the lanes popup —
  all poll, because a poll is the only thing that works with no bridge.
  With one, they poll on a long backstop and refresh the moment something
  actually happens, which is both faster to notice a merge and far less
  traffic on your API budget.

  The delivery signature is the entire auth story, so the receiver needs
  the shared secret before it will start: this room declares
  GITHUB\_WEBHOOK\_SECRET to the secrets room and `haus-secret --check`
  asks you for it once. Until it has one the receiver stays dormant and
  says so in its log — the build is fine, the bridge simply isn't up.
  `secretCommand` is there for fetching that value some other way.
</details>

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.forwardTo` [#haus-github-forwardto]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It names a device on your own network by host or IP, which is a fact about your desk. Left empty, the pill discovers the device itself, and that is what a shared desktop should leave it doing.

Where to repeat each verified delivery, verbatim — same body, same
`X-Hub-Signature-256`, same event and delivery headers — as `host:port`.

Verbatim is the point. The far end verifies the signature itself and
trusts nothing about this process, so putting haus's receiver in front
of an app that already speaks GitHub webhooks changes nothing about that
app. `127.0.0.1:42787` is trill's own bridge, which is the case this
exists for: trill keeps mapping deliveries into banners exactly as it
did when GitHub called it directly.

Fire-and-forget, five second timeout, and never waited on: a slow
forward target must not be able to spend GitHub's delivery timeout.

Example:

```nix
[
  "127.0.0.1:42787"
]
```

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.hooks` [#haus-github-hooks]

`list of (submodule)` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It names something inside one person's GitHub or Cloudflare account — an organisation whose repositories this Mac watches, a hostname you own, the tunnel that carries traffic to your desk. A desktop that set it would point a stranger's machine at your account, and in the webhook's case would tell them where to send deliveries.

The hooks this machine WANTS to exist on GitHub. Declaring one creates
nothing: haus holds no token and never writes to GitHub. What the
declaration does is make two questions answerable.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.github.hooks</span>
  </summary>

  The first is coverage. A room only stops polling a repository once
  something says deliveries for it will actually arrive, and "an active
  hook whose scope covers this repository, whose event list includes what
  I read, and whose last delivery GitHub did not record as failed" is that
  something. `github-signal` asks GitHub for those facts on a slow timer
  and caches the answer; the surfaces read the cache.

  The second is drift. `haus doctor` diffs this list against the hooks
  that really exist and prints the `gh api` command that closes the gap —
  a missing event being the failure worth catching, because a hook
  subscribed to four of the five events you care about looks entirely
  healthy from every side.
</details>

Example:

```nix
[
  {
    events = [
      "pull_request"
      "workflow_run"
    ];
    scope = "org:hausfold";
  }
]
```

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.hooks.*.events` [#haus-github-hooks-events]

`list of string` · see below

**Host-only.** A shared desktop may not set it; only your host file can. It names something inside one person's GitHub or Cloudflare account — an organisation whose repositories this Mac watches, a hostname you own, the tunnel that carries traffic to your desk. A desktop that set it would point a stranger's machine at your account, and in the webhook's case would tell them where to send deliveries.

The GitHub event names the hook should be subscribed to, spelled
as GitHub spells them.

The default is the set the surfaces in this house actually read:
pull request lifecycle and reviews for "is this landable", CI
runs for "is main red", comments for "did someone say my name".
`pull_request_review` earns its place specifically — approvals
are what turn a row green, and a hook without it looks like it
is working while the one state you check most never arrives.

Example:

```nix
[
  "pull_request"
]
```

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.hooks.*.scope` [#haus-github-hooks-scope]

`string` · no default · e.g. `"org:hausfold"`

**Host-only.** A shared desktop may not set it; only your host file can. It names something inside one person's GitHub or Cloudflare account — an organisation whose repositories this Mac watches, a hostname you own, the tunnel that carries traffic to your desk. A desktop that set it would point a stranger's machine at your account, and in the webhook's case would tell them where to send deliveries.

What the hook is attached to: `org:<name>` for an organisation
hook (which covers every repository in it, including ones
created later) or `repo:<owner>/<name>` for a single repository.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.port` [#haus-github-port]

`16 bit unsigned integer; between 0 and 65535 (both inclusive)` · default `42786` · e.g. `8099`

The loopback port the receiver binds. Never exposed: the public leg is
the tunnel's, and the socket is 127.0.0.1 only.

It is an option because it has to agree with two other things — the
tunnel's ingress (which this room writes, so that half is automatic) and
whatever else on the Mac already wants the port. trill's own GitHub
bridge listens on 42787, which is why the default sits one below it:
the usual arrangement is deliveries landing here and being forwarded
there, and the two cannot share a socket.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.secretCommand` [#haus-github-secretcommand]

`string` · default `""`

**Host-only.** A shared desktop may not set it; only your host file can. It points at a secret, or at the store this machine keeps its secrets in, so it belongs to one person on one Mac.

Shell printing the webhook's HMAC secret on stdout — the same string
entered in GitHub's hook settings. Run when the receiver starts, and
its output is cached in `~/.local/state/haus/github/secret` (mode 600),
which is what the receiver reads.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.github.secretCommand</span>
  </summary>

  A command rather than a value so the secret never enters the Nix store,
  where it would be world-readable and preserved in every generation.

  EMPTY (the default) means haus holds it: this room declares
  GITHUB\_WEBHOOK\_SECRET to the secrets room, `haus-secret --check` asks
  you for it once, and `haus.secrets.provider` decides where it is kept.
  Set this only to fetch the value some other way — and note that doing
  so withdraws the declaration, since a manifest entry nothing reads is a
  value the wizard would ask for and never use.

  Either way an absent value is a dormant receiver, not a broken build:
  it exits 78 (EX\_CONFIG) and says so in its log, because there is no
  reduced-function receiver that skips signature checks and there should
  not be.
</details>

Example:

```nix
"op read op://private/github-webhook/secret"
```

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.tunnel.credentialsFile` [#haus-github-tunnel-credentialsfile]

`string` · default `""`

**Host-only.** A shared desktop may not set it; only your host file can. It names a path on this disk, so it is a fact about one filesystem rather than an opinion a shared desktop can hold about every machine.

The tunnel credentials `cloudflared tunnel create` wrote. Defaults to
`~/.cloudflared/<id>.json`, which is where it puts them.

Named rather than inlined because it is a secret cloudflared owns and
rotates on its own terms — haus points at it and never reads it. The
agent is also gated on this file existing, so a machine that has not run
the one-time bootstrap gets a dormant agent rather than a crash loop.

Example:

```nix
"/Users/you/.cloudflared/6209f5f4-f8a2-4501-8af9-a8bb24777a89.json"
```

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.tunnel.enable` [#haus-github-tunnel-enable]

`boolean` · default `false`

Run `cloudflared` for the hook's public hostname, pointed at the
receiver's loopback port.

haus writes the ingress config (hostname → `127.0.0.1:<port>`) so the
port stays a single number in one place, but it never creates the
tunnel or its credentials: `cloudflared tunnel login`, `tunnel create`
and `tunnel route dns` are a one-time errand with a browser in it, and
`haus doctor` carries the card that says so. Until the credentials file
exists the agent stays dormant rather than crash-looping.

Separate from `enable` because the two are genuinely separable: a Mac
reachable some other way (a static address, someone else's tunnel, a
relay) wants the receiver without this.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.tunnel.hostname` [#haus-github-tunnel-hostname]

`string` · default `""` · e.g. `"hooks.example.com"`

**Host-only.** A shared desktop may not set it; only your host file can. It names something inside one person's GitHub or Cloudflare account — an organisation whose repositories this Mac watches, a hostname you own, the tunnel that carries traffic to your desk. A desktop that set it would point a stranger's machine at your account, and in the webhook's case would tell them where to send deliveries.

The public hostname the tunnel answers on — the same URL entered in
GitHub's hook settings, minus the scheme and path.

It must already be routed to the tunnel (`cloudflared tunnel route dns
&lt;tunnel> &lt;hostname>`), which is part of the same one-time errand as
creating it.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

#### `haus.github.tunnel.id` [#haus-github-tunnel-id]

`string` · default `""` · e.g. `"6209f5f4-f8a2-4501-8af9-a8bb24777a89"`

**Host-only.** A shared desktop may not set it; only your host file can. It names something inside one person's GitHub or Cloudflare account — an organisation whose repositories this Mac watches, a hostname you own, the tunnel that carries traffic to your desk. A desktop that set it would point a stranger's machine at your account, and in the webhook's case would tell them where to send deliveries.

The Cloudflare tunnel's UUID, as `cloudflared tunnel create` printed it.

The UUID rather than the name: the name is a label that can be reused
across accounts, and the credentials file is named for the UUID, so this
is the one spelling where the config and the credentials cannot end up
describing different tunnels.

<small>
  Declared in 

  [`modules/github/options.nix`](https://github.com/hausfold/haus/blob/main/modules/github/options.nix)

  .
</small>

## Windows [#windows]

Tiling, window navigation, hot corners, and the leader key that launches an app or throws it somewhere — plus macOS's own Stage Manager, edge-drag tiling and desktop clutter, which answer the same question the tiler does. The workspaces themselves (`haus.workspaces`) and the keys haus claims (`haus.keys`) are shared surfaces below, because the bar and the launcher read them too.

### haus.hotCorners [#haushotcorners]

What each corner of the screen does when the pointer reaches it. Every corner is unset by default, so haus never overwrites one you set yourself.

<div className="hf-optindex">
  [`bottomLeft`](#haus-hotcorners-bottomleft) [`bottomRight`](#haus-hotcorners-bottomright) [`topLeft`](#haus-hotcorners-topleft) [`topRight`](#haus-hotcorners-topright)
</div>

#### `haus.hotCorners.bottomLeft` [#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` · e.g. `"mission-control"`

What happens when the pointer reaches the bottom-left corner of the main
display.

```
              disabled  nothing happens — the corner is explicitly claimed and left inert
       mission-control  Mission Control: every window and Space, zoomed out
   application-windows  App Exposé: every window of the app you're in
               desktop  push all windows aside and show the desktop
             launchpad  the grid of installed apps (on macOS 26 this opens the Apps view)
   notification-center  slide out Notification Center and its widgets
            quick-note  start a Quick Note — Apple's own default for the bottom-right corner
          screen-saver  start the screen saver immediately
  prevent-screen-saver  hold the screen saver off while the pointer rests here
         sleep-display  put the display to sleep (the machine keeps running)
           lock-screen  lock the screen and return to the login window
```

null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.

Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.

Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which the tiler replaces. They still
work, they just show you a view of the windows the tiler is arranging.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.hotCorners.bottomRight` [#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` · e.g. `"mission-control"`

What happens when the pointer reaches the bottom-right corner of the main
display.

```
              disabled  nothing happens — the corner is explicitly claimed and left inert
       mission-control  Mission Control: every window and Space, zoomed out
   application-windows  App Exposé: every window of the app you're in
               desktop  push all windows aside and show the desktop
             launchpad  the grid of installed apps (on macOS 26 this opens the Apps view)
   notification-center  slide out Notification Center and its widgets
            quick-note  start a Quick Note — Apple's own default for the bottom-right corner
          screen-saver  start the screen saver immediately
  prevent-screen-saver  hold the screen saver off while the pointer rests here
         sleep-display  put the display to sleep (the machine keeps running)
           lock-screen  lock the screen and return to the login window
```

null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.

Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.

Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which the tiler replaces. They still
work, they just show you a view of the windows the tiler is arranging.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.hotCorners.topLeft` [#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` · e.g. `"mission-control"`

What happens when the pointer reaches the top-left corner of the main
display.

```
              disabled  nothing happens — the corner is explicitly claimed and left inert
       mission-control  Mission Control: every window and Space, zoomed out
   application-windows  App Exposé: every window of the app you're in
               desktop  push all windows aside and show the desktop
             launchpad  the grid of installed apps (on macOS 26 this opens the Apps view)
   notification-center  slide out Notification Center and its widgets
            quick-note  start a Quick Note — Apple's own default for the bottom-right corner
          screen-saver  start the screen saver immediately
  prevent-screen-saver  hold the screen saver off while the pointer rests here
         sleep-display  put the display to sleep (the machine keeps running)
           lock-screen  lock the screen and return to the login window
```

null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.

Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.

Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which the tiler replaces. They still
work, they just show you a view of the windows the tiler is arranging.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.hotCorners.topRight` [#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` · e.g. `"mission-control"`

What happens when the pointer reaches the top-right corner of the main
display.

```
              disabled  nothing happens — the corner is explicitly claimed and left inert
       mission-control  Mission Control: every window and Space, zoomed out
   application-windows  App Exposé: every window of the app you're in
               desktop  push all windows aside and show the desktop
             launchpad  the grid of installed apps (on macOS 26 this opens the Apps view)
   notification-center  slide out Notification Center and its widgets
            quick-note  start a Quick Note — Apple's own default for the bottom-right corner
          screen-saver  start the screen saver immediately
  prevent-screen-saver  hold the screen saver off while the pointer rests here
         sleep-display  put the display to sleep (the machine keeps running)
           lock-screen  lock the screen and return to the login window
```

null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.

Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.

Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which the tiler replaces. They still
work, they just show you a view of the windows the tiler is arranging.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.windows [#hauswindows]

Tiling window management and the Caps-Lock leader launcher — plus macOS's OWN window features (Stage Manager, edge-drag tiling, the desktop's icons and widgets), which live here rather than with the other macOS settings because they decide the same thing the tiler does and haus warns when both are on.

<div className="hf-optindex">
  [`accordionPadding`](#haus-windows-accordionpadding) [`defaultLayout`](#haus-windows-defaultlayout) [`defaultOrientation`](#haus-windows-defaultorientation) [`desktop.clickToReveal`](#haus-windows-desktop-clicktoreveal) [`desktop.hideIcons`](#haus-windows-desktop-hideicons) [`desktop.hideWidgets`](#haus-windows-desktop-hidewidgets) [`enable`](#haus-windows-enable) [`gaps.inner.builtin`](#haus-windows-gaps-inner-builtin) [`gaps.inner.external`](#haus-windows-gaps-inner-external) [`gaps.outer.left.builtin`](#haus-windows-gaps-outer-left-builtin) [`gaps.outer.left.external`](#haus-windows-gaps-outer-left-external) [`gaps.outer.right.builtin`](#haus-windows-gaps-outer-right-builtin) [`gaps.outer.right.external`](#haus-windows-gaps-outer-right-external) [`gravity`](#haus-windows-gravity) [`mouseFollowsFocus`](#haus-windows-mousefollowsfocus) [`mouseFullscreen`](#haus-windows-mousefullscreen) [`nativeTiling.edgeDrag`](#haus-windows-nativetiling-edgedrag) [`nativeTiling.margins`](#haus-windows-nativetiling-margins) [`nativeTiling.optionAccelerator`](#haus-windows-nativetiling-optionaccelerator) [`nativeTiling.topEdgeFullscreen`](#haus-windows-nativetiling-topedgefullscreen) [`numberedWorkspaces`](#haus-windows-numberedworkspaces) [`stageManager.autoHideStrip`](#haus-windows-stagemanager-autohidestrip) [`stageManager.enable`](#haus-windows-stagemanager-enable) [`stageManager.groupWindows`](#haus-windows-stagemanager-groupwindows) [`stageManager.hideDesktopIcons`](#haus-windows-stagemanager-hidedesktopicons) [`stageManager.hideWidgets`](#haus-windows-stagemanager-hidewidgets) [`workspaceMonitors`](#haus-windows-workspacemonitors)
</div>

#### `haus.windows.accordionPadding` [#haus-windows-accordionpadding]

`unsigned integer, meaning >=0` · default `80` · e.g. `30`

How much room an accordion layout leaves for the windows either side
of the focused one, in points. Bigger means a narrower focused window
with more of the stack peeking out; `0` hides the neighbours
completely, which makes an accordion workspace look like a fullscreen
one.

Only read while a workspace is in the accordion layout — whether it
got there from haus.windows.defaultLayout, from the layout chord, or
from the leader-key tiling dial, which carries accordion as one of its
three stops.

Only meaningful with haus.windows.enable.

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.defaultLayout` [#haus-windows-defaultlayout]

`one of "tiles", "accordion"` · default `"tiles"`

What a fresh workspace does with the windows on it.

"tiles" divides the space between them, which is what most people mean
by a tiling window manager. "accordion" stacks them and gives the
focused one the room, leaving a sliver of its neighbours showing:
closer to how a browser's tabs behave, and easier on a laptop display
where a third split stops being readable.

A default, not a lock. The layout chords still switch the current
workspace either way, whichever modifier haus.keys.windowNav puts them
on.

Only meaningful with haus.windows.enable.

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.defaultOrientation` [#haus-windows-defaultorientation]

`one of "auto", "horizontal", "vertical"` · default `"auto"`

Which way a fresh workspace's first split runs.

"auto" decides per monitor from its shape — side by side on a wide
screen, stacked on a tall one — which is right almost always and is
why it is the default. The other two are for a machine that should
split the same way whatever display it wakes up on.

Only meaningful with haus.windows.enable.

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.desktop.clickToReveal` [#haus-windows-desktop-clicktoreveal]

`null or boolean` · default `null` · e.g. `true`

Click the wallpaper to push every window aside and reveal the desktop.
macOS 14 turned this on for everybody and it is the change most people
want back: true is "always", false is "only in Stage Manager".

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.desktop.clickToReveal</span>
  </summary>

  Worth knowing before you leave it on with a tiler: a click on the
  wallpaper is easy to make by accident on a workspace whose windows do
  not cover the screen, and it moves every window on it.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.desktop.hideIcons` [#haus-windows-desktop-hideicons]

`null or boolean` · default `null` · e.g. `true`

Hide the icons on your desktop, always — the files are still in
`~/Desktop`, Finder still shows them, they just stop being drawn on
the wallpaper.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.desktop.hideIcons</span>
  </summary>

  The natural companion to a generated `haus.wallpaper`, which you chose
  to look at rather than to be a filing cabinet.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.desktop.hideWidgets` [#haus-windows-desktop-hidewidgets]

`null or boolean` · default `null` · e.g. `true`

Hide desktop widgets, always. Same idea as `hideIcons`, for the
clock/calendar/weather widgets macOS 14 let you park on the desktop.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.desktop.hideWidgets</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.enable` [#haus-windows-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.

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gaps.inner.builtin` [#haus-windows-gaps-inner-builtin]

`unsigned integer, meaning >=0` · default `10` · e.g. `0`

How much space AeroSpace leaves BETWEEN two tiled windows on the built-in display, in points.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gaps.inner.builtin</span>
  </summary>

  Two numbers rather than one because AeroSpace takes a gap per
  monitor and the two displays want different ones: haus ships 10 on
  the built-in and 20 around an external, which is the same gap
  reading the same size on panels of very different pitch.

  This is the gap at `haus.ui.scale = 1.0`, not the finished number —
  it is multiplied by the scale like every other tuned measurement, so
  a bigger desktop keeps its proportions. `0` is `0` at every scale,
  which is what to set for a desktop with no gaps at all.

  `inner`, `outer.left` and `outer.right` are the settable gaps.
  `outer.top` and `outer.bottom` are not, and deliberately: those two
  carry the bar's reservation
  (`modules/lib/gaps.nix`), which is the only thing keeping tiled
  windows out from under it — a `0` there would not be a tighter
  desktop, it would be windows drawn beneath the bar. With
  `haus.bar.enable = false` they carry no reservation either, and
  fall back to the shipped 10/20 rather than to anything you can set.

  These are read whether or not the tiler is on. AeroSpace is the
  only thing that acts on them, but three surfaces are DRAWN
  against them — the bar's left and right padding, the
  near-fullscreen terminal popups, and the wallpaper's debug band
  — so retuning a gap moves those on a machine with
  haus.windows.enable = false too.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gaps.inner.external` [#haus-windows-gaps-inner-external]

`unsigned integer, meaning >=0` · default `20` · e.g. `0`

How much space AeroSpace leaves BETWEEN two tiled windows on an external display, in points.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gaps.inner.external</span>
  </summary>

  Two numbers rather than one because AeroSpace takes a gap per
  monitor and the two displays want different ones: haus ships 10 on
  the built-in and 20 around an external, which is the same gap
  reading the same size on panels of very different pitch.

  This is the gap at `haus.ui.scale = 1.0`, not the finished number —
  it is multiplied by the scale like every other tuned measurement, so
  a bigger desktop keeps its proportions. `0` is `0` at every scale,
  which is what to set for a desktop with no gaps at all.

  `inner`, `outer.left` and `outer.right` are the settable gaps.
  `outer.top` and `outer.bottom` are not, and deliberately: those two
  carry the bar's reservation
  (`modules/lib/gaps.nix`), which is the only thing keeping tiled
  windows out from under it — a `0` there would not be a tighter
  desktop, it would be windows drawn beneath the bar. With
  `haus.bar.enable = false` they carry no reservation either, and
  fall back to the shipped 10/20 rather than to anything you can set.

  These are read whether or not the tiler is on. AeroSpace is the
  only thing that acts on them, but three surfaces are DRAWN
  against them — the bar's left and right padding, the
  near-fullscreen terminal popups, and the wallpaper's debug band
  — so retuning a gap moves those on a machine with
  haus.windows.enable = false too.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gaps.outer.left.builtin` [#haus-windows-gaps-outer-left-builtin]

`unsigned integer, meaning >=0` · default `10` · e.g. `0`

How much space AeroSpace leaves at the LEFT edge of the screen on the built-in display, in points.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gaps.outer.left.builtin</span>
  </summary>

  Two numbers rather than one because AeroSpace takes a gap per
  monitor and the two displays want different ones: haus ships 10 on
  the built-in and 20 around an external, which is the same gap
  reading the same size on panels of very different pitch.

  This is the gap at `haus.ui.scale = 1.0`, not the finished number —
  it is multiplied by the scale like every other tuned measurement, so
  a bigger desktop keeps its proportions. `0` is `0` at every scale,
  which is what to set for a desktop with no gaps at all.

  `inner`, `outer.left` and `outer.right` are the settable gaps.
  `outer.top` and `outer.bottom` are not, and deliberately: those two
  carry the bar's reservation
  (`modules/lib/gaps.nix`), which is the only thing keeping tiled
  windows out from under it — a `0` there would not be a tighter
  desktop, it would be windows drawn beneath the bar. With
  `haus.bar.enable = false` they carry no reservation either, and
  fall back to the shipped 10/20 rather than to anything you can set.

  These are read whether or not the tiler is on. AeroSpace is the
  only thing that acts on them, but three surfaces are DRAWN
  against them — the bar's left and right padding, the
  near-fullscreen terminal popups, and the wallpaper's debug band
  — so retuning a gap moves those on a machine with
  haus.windows.enable = false too.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gaps.outer.left.external` [#haus-windows-gaps-outer-left-external]

`unsigned integer, meaning >=0` · default `20` · e.g. `0`

How much space AeroSpace leaves at the LEFT edge of the screen on an external display, in points.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gaps.outer.left.external</span>
  </summary>

  Two numbers rather than one because AeroSpace takes a gap per
  monitor and the two displays want different ones: haus ships 10 on
  the built-in and 20 around an external, which is the same gap
  reading the same size on panels of very different pitch.

  This is the gap at `haus.ui.scale = 1.0`, not the finished number —
  it is multiplied by the scale like every other tuned measurement, so
  a bigger desktop keeps its proportions. `0` is `0` at every scale,
  which is what to set for a desktop with no gaps at all.

  `inner`, `outer.left` and `outer.right` are the settable gaps.
  `outer.top` and `outer.bottom` are not, and deliberately: those two
  carry the bar's reservation
  (`modules/lib/gaps.nix`), which is the only thing keeping tiled
  windows out from under it — a `0` there would not be a tighter
  desktop, it would be windows drawn beneath the bar. With
  `haus.bar.enable = false` they carry no reservation either, and
  fall back to the shipped 10/20 rather than to anything you can set.

  These are read whether or not the tiler is on. AeroSpace is the
  only thing that acts on them, but three surfaces are DRAWN
  against them — the bar's left and right padding, the
  near-fullscreen terminal popups, and the wallpaper's debug band
  — so retuning a gap moves those on a machine with
  haus.windows.enable = false too.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gaps.outer.right.builtin` [#haus-windows-gaps-outer-right-builtin]

`unsigned integer, meaning >=0` · default `10` · e.g. `0`

How much space AeroSpace leaves at the RIGHT edge of the screen on the built-in display, in points.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gaps.outer.right.builtin</span>
  </summary>

  Two numbers rather than one because AeroSpace takes a gap per
  monitor and the two displays want different ones: haus ships 10 on
  the built-in and 20 around an external, which is the same gap
  reading the same size on panels of very different pitch.

  This is the gap at `haus.ui.scale = 1.0`, not the finished number —
  it is multiplied by the scale like every other tuned measurement, so
  a bigger desktop keeps its proportions. `0` is `0` at every scale,
  which is what to set for a desktop with no gaps at all.

  `inner`, `outer.left` and `outer.right` are the settable gaps.
  `outer.top` and `outer.bottom` are not, and deliberately: those two
  carry the bar's reservation
  (`modules/lib/gaps.nix`), which is the only thing keeping tiled
  windows out from under it — a `0` there would not be a tighter
  desktop, it would be windows drawn beneath the bar. With
  `haus.bar.enable = false` they carry no reservation either, and
  fall back to the shipped 10/20 rather than to anything you can set.

  These are read whether or not the tiler is on. AeroSpace is the
  only thing that acts on them, but three surfaces are DRAWN
  against them — the bar's left and right padding, the
  near-fullscreen terminal popups, and the wallpaper's debug band
  — so retuning a gap moves those on a machine with
  haus.windows.enable = false too.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gaps.outer.right.external` [#haus-windows-gaps-outer-right-external]

`unsigned integer, meaning >=0` · default `20` · e.g. `0`

How much space AeroSpace leaves at the RIGHT edge of the screen on an external display, in points.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gaps.outer.right.external</span>
  </summary>

  Two numbers rather than one because AeroSpace takes a gap per
  monitor and the two displays want different ones: haus ships 10 on
  the built-in and 20 around an external, which is the same gap
  reading the same size on panels of very different pitch.

  This is the gap at `haus.ui.scale = 1.0`, not the finished number —
  it is multiplied by the scale like every other tuned measurement, so
  a bigger desktop keeps its proportions. `0` is `0` at every scale,
  which is what to set for a desktop with no gaps at all.

  `inner`, `outer.left` and `outer.right` are the settable gaps.
  `outer.top` and `outer.bottom` are not, and deliberately: those two
  carry the bar's reservation
  (`modules/lib/gaps.nix`), which is the only thing keeping tiled
  windows out from under it — a `0` there would not be a tighter
  desktop, it would be windows drawn beneath the bar. With
  `haus.bar.enable = false` they carry no reservation either, and
  fall back to the shipped 10/20 rather than to anything you can set.

  These are read whether or not the tiler is on. AeroSpace is the
  only thing that acts on them, but three surfaces are DRAWN
  against them — the bar's left and right padding, the
  near-fullscreen terminal popups, and the wallpaper's debug band
  — so retuning a gap moves those on a machine with
  haus.windows.enable = false too.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.gravity` [#haus-windows-gravity]

`boolean` · default `true` · e.g. `false`

When the focused workspace loses its last window, pull back to the most
recently populated one instead of leaving you on a blank screen.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.gravity</span>
  </summary>

  Both ways a workspace empties count: a ⌘Q that takes every window of an
  app at once, and the close of the LAST window on a page — a ⌘W on a lane,
  a ⌃D that ends a shell — while that app carries on in windows elsewhere.
  The second matters most on the `T/<repo>` lane pages, which are not
  persistent: without it you were left standing on a page that no longer
  appears in any list, with nothing on it.

  It fires only for a workspace you EMPTIED — never for one you
  deliberately navigated to that happens to be empty — so in ordinary use
  it is the difference between closing the last thing on a space and then
  having to find your way off it.

  A workspace and its pages are one family, and gravity prefers it:
  emptying `T/<repo>` lands on the most recent populated member of the T
  family — another page, even one never visited, or T itself — and only
  when the whole family is empty does it fall back to the most recently
  populated workspace anywhere. That holds when macOS moves you first,
  too: ending the agent in a lane quits that lane's Ghostty, macOS hands
  focus to the next app, and AeroSpace follows it to its workspace. If
  that lands you outside the family while a member is still populated,
  gravity takes you back into it; otherwise macOS's pick stands.

  Turn it off if a screen that changes without you touching it is worse
  than a blank one. That is what `haus.appearance.reduceMotion` decides on
  your behalf: it is the largest movement haus makes that you did not ask
  for, and one whole display's worth of content replaced in a blink is
  exactly what a vestibular trigger looks like.

  Needs both `haus.windows.enable` and `haus.bar.enable`: the emptying is
  detected from the bar's own event stream (see
  modules/bar/sketchybar/plugins/empty\_workspace.sh for why there is no
  AeroSpace hook for it), so with no bar there is no gravity either way.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.mouseFollowsFocus` [#haus-windows-mousefollowsfocus]

`boolean` · default `false`

Move the pointer to whatever keyboard focus just landed on.

Off by default, because it moves something you did not move. On a
multi-monitor desk it is the setting that answers "I focused that
window and my cursor is still on the other screen".

Both halves are lazy: the pointer is left alone whenever it is already
inside the window (or on the monitor) that took focus, so it moves on
the jumps that lose it and not on the ones that don't.

Only meaningful with haus.windows.enable.

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.mouseFullscreen` [#haus-windows-mousefullscreen]

`one of "right", "left", "none"` · default `"none"` · e.g. `"right"`

Zoom the window **under the pointer** with a modifier + a click.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.mouseFullscreen</span>
  </summary>

  The keyboard's `<mod>f` can only ever reach the window you are already
  in; this is its pointer twin, so zooming a window on the other monitor
  is one gesture rather than focus-then-zoom. It is the same AeroSpace
  `fullscreen` either way — a toggle, so the same click puts it back —
  and the click focuses the window on its way in.

  The modifier is NOT separately settable: it follows
  `haus.keys.windowNav`, so `<mod>f` and `<mod>`+click are one
  vocabulary and a machine that moved the modifier for its keyboard
  layout moves the mouse chord with it. `windowNav = "none"` therefore
  leaves nothing to hold, and an assertion refuses the pair rather than
  letting a bare click be bound — a modifier-less chord would swallow
  every click on the machine.

  Which button, and why the default is the right one: **the click is
  consumed over every app**, so this spends a gesture machine-wide.
  `"right"` is ⌥ + right-click, the quietest of the three rather than a
  free one: macOS uses ⌥ as the ALTERNATE-contextual-menu modifier, so
  inside a Finder window it is what turns "Copy X" into "Copy X as
  Pathname", and an app that reads ⌥ + right-DRAG (a 3D viewport zoom)
  loses that too, since the mouse-down is swallowed before the drag
  starts. The desktop passes through, so only in-window menus are
  affected. `"left"`
  is ⌥ + left-click, which costs considerably more — multi-cursor in GUI editors,
  ⌥-click-a-link to download, ⌥-click on a menu extra, and every ⌥-drag
  (the mouse-down is swallowed, so the drag never begins) — offered
  because on some mice the right button is the awkward one, not because
  it is a peer of `"right"`. There is deliberately no ctrl option:
  ctrl+click IS macOS's secondary click, so binding it would cost
  context menus everywhere.

  Clicking the desktop passes through untouched, and anything drawn
  above ordinary windows — the menu bar, the Dock, the bar — is
  transparent to the chord rather than being "clicked".

  Carried by the launcher's event tap (AeroSpace has no mouse bindings at all,
  and Ghostty's keybind triggers are keys), so it needs
  haus.launcher.enable and Pounce's Accessibility grant, which it already asks
  for; an assertion catches the first, and without the second the click
  simply keeps its stock meaning.

  Only meaningful with haus.windows.enable.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.nativeTiling.edgeDrag` [#haus-windows-nativetiling-edgedrag]

`null or boolean` · default `null` · e.g. `true`

Drag a window to the side of the screen and macOS tiles it there.
On (macOS's own default) unless you say otherwise.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.nativeTiling.edgeDrag</span>
  </summary>

  THE ONE TO TURN OFF IF YOU TILE. With `haus.windows.enable` on,
  AeroSpace already owns where windows go, and this is the setting that
  makes a window you were merely dragging past the edge of the screen
  snap to half of it — which then fights the tiler for the same space.
  Setting it false is the single most useful key in this group for a
  tiling machine, and haus warns about the combination.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.nativeTiling.margins` [#haus-windows-nativetiling-margins]

`null or boolean` · default `null` · e.g. `true`

Leave a gap between natively tiled windows and the screen edges.
macOS's own gaps setting, and nothing to do with
`haus.windows.accordionPadding` or AeroSpace's gaps, which apply to
windows the TILER placed.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.nativeTiling.margins</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.nativeTiling.optionAccelerator` [#haus-windows-nativetiling-optionaccelerator]

`null or boolean` · default `null` · e.g. `true`

Hold ⌥ while dragging to tile a window.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.nativeTiling.optionAccelerator</span>
  </summary>

  READ THIS WITH `edgeDrag`, not instead of it. These are two
  independent keys and macOS ships both on, so turning this on does NOT
  stop a bare drag from tiling — that is `edgeDrag = false`, and what
  this adds is a second, deliberate way in. The pair people usually
  want is `edgeDrag = false` here and `optionAccelerator = true`:
  native tiling stays available on the machine and stops happening by
  accident, which on a tiling machine is the whole complaint.

  On a tiling machine it also spends a modifier AeroSpace may want —
  check `haus.keys.windowNav` before relying on it.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.nativeTiling.topEdgeFullscreen` [#haus-windows-nativetiling-topedgefullscreen]

`null or boolean` · default `null` · e.g. `true`

Drag a window up to the menu bar and macOS fills the screen with it.
The same bargain as `edgeDrag`, at the top edge, and worth turning off
for the same reason if you tile: the menu bar is somewhere a window
gets dragged PAST, on the way to somewhere else.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.nativeTiling.topEdgeFullscreen</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.numberedWorkspaces` [#haus-windows-numberedworkspaces]

`integer between 0 and 10 (both inclusive)` · default `4` · e.g. `6`

How many numbered workspaces exist, on top of whatever
haus.workspaces names. Four is the house default; the ceiling is ten
because the leader reaches them by DIGIT and there are only ten
digits: 1-9 in order, then `0` for the tenth, the same wrap a
browser's tab shortcuts use.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.numberedWorkspaces</span>
  </summary>

  Each one gets three leader bindings (the digit focuses it, ⇧+the digit
  throws the focused window there and follows, ⌥⇧+the digit throws
  without following), a bar pill, and a `persistent-workspace` entry, so
  the workspace exists whether or not a window is on it.

  `0` is legal and means no numbered workspaces at all: a machine where
  every workspace is a NAMED one out of haus.workspaces. Raising the
  count never renames an existing workspace, so windows stay where they
  are. Lowering it takes the digit AND the bar pill, so whatever was on
  the workspaces that went is reachable only through the launcher's ⌘⇥
  window switcher until you raise the count again.

  The digits are reserved in launch mode, so a haus.workspaces key or a
  haus.keys.leaderExtras key that collides with one is refused at eval.
  This count decides which digits that means.

  To pin them to a display — 1-4 on one screen, 5-8 on another — see
  haus.windows.workspaceMonitors below, which takes these ids and
  haus.workspaces names alike.

  Only meaningful with haus.windows.enable.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.stageManager.autoHideStrip` [#haus-windows-stagemanager-autohidestrip]

`null or boolean` · default `null` · e.g. `true`

Hide the strip of recent apps until the pointer goes near it, instead
of keeping it on screen. Only does anything while Stage Manager is on.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.stageManager.autoHideStrip</span>
  </summary>

  The setting that buys back the width Stage Manager costs you on a
  laptop display.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.stageManager.enable` [#haus-windows-stagemanager-enable]

`null or boolean` · default `null` · e.g. `true`

macOS's Stage Manager: recent windows are swept into a strip down the
side of the screen, one app in the middle at a time.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.stageManager.enable</span>
  </summary>

  Turning this on while `haus.windows.enable` is also on gives you two
  window managers with different ideas about where a window belongs —
  AeroSpace tiles it, Stage Manager pulls it back to the strip. haus
  warns about the pair rather than refusing it, because "Stage Manager
  on the laptop display, tiling on the external" is a real way to work,
  but if windows will not stay where the tiler puts them this is the
  first thing to turn off.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.stageManager.groupWindows` [#haus-windows-stagemanager-groupwindows]

`null or boolean` · default `null` · e.g. `true`

When you click an app in the strip, whether Stage Manager brings ALL
of that app's windows forward together (true) or one at a time
(false). macOS spells these "All at once" and "One at a time".

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.stageManager.groupWindows</span>
  </summary>

  True is what you want for an app you keep several windows of and read
  side by side; false keeps the middle of the screen to a single window,
  which is the point of Stage Manager for most people.

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.stageManager.hideDesktopIcons` [#haus-windows-stagemanager-hidedesktopicons]

`null or boolean` · default `null` · e.g. `true`

Hide the icons on your desktop while Stage Manager is on. The
Stage-Manager-only twin of `haus.windows.desktop.hideIcons`, which
hides them always.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.stageManager.hideDesktopIcons</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.stageManager.hideWidgets` [#haus-windows-stagemanager-hidewidgets]

`null or boolean` · default `null` · e.g. `true`

Hide desktop widgets while Stage Manager is on. The
Stage-Manager-only twin of `haus.windows.desktop.hideWidgets`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.stageManager.hideWidgets</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off": these are settings people have usually already made by hand,
  and a rebuild that named one it didn't care about would silently
  overwrite a choice. Turning an option back to null STOPS writing, it
  does not restore — macOS keeps no memory of what the value was.

  TAKES EFFECT AT YOUR NEXT LOGIN. The write lands during the rebuild (no
  permission is involved, and `haus diff` will show the new value straight
  away), but macOS reads this domain when your login session starts and
  offers nothing that makes it re-read: there is no process to restart the
  way Finder or the menu bar can be restarted. So a rebuild that changes
  this option leaves your desktop behaving exactly as it did until you log
  out and back in — which is a wait, not a failure, and nothing is in a
  half-applied state meanwhile.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

#### `haus.windows.workspaceMonitors` [#haus-windows-workspacemonitors]

`attribute set of (string or list of string)` · default `{ }`

**Desktop-safe per key** (`monitor-selectors`). Keys are plain workspace names, and each value pins one to a display by POSITION — `main`, `secondary`, or a number from 1 counting left to right — because a monitor's name or a regex over it identifies one physical panel on one desk, the same reason a display UUID is host-only. `built-in` is a name too, and a localized one. A list of positions is a fallback chain, tried in order.

Pin a workspace to a display, so it always opens on the same screen.
AeroSpace's `workspace-to-monitor-force-assignment`, keyed by the
workspace id: a numbered one (`"1"`, `"2"` — how many exist is
haus.windows.numberedWorkspaces) or a haus.workspaces name.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.windows.workspaceMonitors</span>
  </summary>

  A value is either one pattern or a list of them tried in order, which
  is how a workspace survives the monitor it wants being unplugged. Each
  pattern is one of:

  * `main` or `secondary` — the position, true on any desk;
  * a number — the display's position left to right, counting from `1`;
  * part of the display's name, matched case-insensitively (`Dell`);
  * a regex, when it is wrapped in `^` and `$` (`^built-in retina display$`).

  The last two name one physical panel, so they are a fact about your desk
  rather than a taste: a shared desktop may only use the first two, and the
  seam refuses the others (`modules/lib/desktop.nix`). Your own host file
  can say any of them.

  Note that `built-in` is one of those names, not a position: AeroSpace
  has keywords for `main` and `secondary` only, and matches anything else
  against the display's LOCALIZED name — so `built-in` works on an
  English-language MacBook and matches nothing on a Mac mini.

  A pattern AeroSpace would refuse — an empty string, monitor `0`, or one
  carrying an apostrophe or a newline — is refused here first. Every one
  of those stops `aerospace.toml` parsing, and AeroSpace answers an
  unparseable config by keeping the one it already had, so the cost is
  every binding in the file rather than this one line.

  Naming a workspace that does not exist is refused at eval rather than
  ignored: AeroSpace drops an assignment for an unknown workspace in
  silence, which reads exactly like the option not working.

  Empty (the default) assigns nothing, and AeroSpace puts a workspace on
  whichever display it was last used on.

  Only meaningful with haus.windows.enable.
</details>

Example:

```nix
{
  "1" = "main";
  "2" = "main";
  "3" = "secondary";
  "4" = "secondary";
  T = [
    "Dell U2720Q"
    "main"
  ];
}
```

<small>
  Declared in 

  [`modules/windows/options.nix`](https://github.com/hausfold/haus/blob/main/modules/windows/options.nix)

  .
</small>

## Bar [#bar]

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

### haus.menuBar [#hausmenubar]

The stock menu bar: what the clock shows, and which Control Center glyphs sit beside it. (The hacker bar itself is `bar`.)

<div className="hf-optindex">
  [`clock.analog`](#haus-menubar-clock-analog) [`clock.format`](#haus-menubar-clock-format) [`clock.showDate`](#haus-menubar-clock-showdate) [`clock.showDayOfWeek`](#haus-menubar-clock-showdayofweek) [`clock.showSeconds`](#haus-menubar-clock-showseconds) [`controlCenter.airdrop`](#haus-menubar-controlcenter-airdrop) [`controlCenter.batteryPercentage`](#haus-menubar-controlcenter-batterypercentage) [`controlCenter.bluetooth`](#haus-menubar-controlcenter-bluetooth) [`controlCenter.displayBrightness`](#haus-menubar-controlcenter-displaybrightness) [`controlCenter.focus`](#haus-menubar-controlcenter-focus) [`controlCenter.nowPlaying`](#haus-menubar-controlcenter-nowplaying) [`controlCenter.sound`](#haus-menubar-controlcenter-sound)
</div>

#### `haus.menuBar.clock.analog` [#haus-menubar-clock-analog]

`null or boolean` · default `null` · e.g. `false`

Draw an analog clock face instead of a digital readout. null (the
default) leaves macOS's own choice alone (digital).

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.clock.format` [#haus-menubar-clock-format]

`null or one of "12h", "24h"` · default `null` · e.g. `"24h"`

12-hour or 24-hour menu bar clock. null (the default) leaves
macOS's own choice alone (region-dependent, usually 12h in the US).

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.clock.showDate` [#haus-menubar-clock-showdate]

`null or one of "when-space-allows", "always", "never"` · default `null` · e.g. `"always"`

Whether the full date appears next to the time. null (the default)
leaves macOS's own choice alone ("when-space-allows").

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.clock.showDayOfWeek` [#haus-menubar-clock-showdayofweek]

`null or boolean` · default `null` · e.g. `true`

Show the day of the week next to the clock. null (the default)
leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.clock.showSeconds` [#haus-menubar-clock-showseconds]

`null or boolean` · default `null` · e.g. `false`

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

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.airdrop` [#haus-menubar-controlcenter-airdrop]

`null or boolean` · default `null` · e.g. `false`

Whether the AirDrop control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.batteryPercentage` [#haus-menubar-controlcenter-batterypercentage]

`null or boolean` · default `null` · e.g. `true`

Show the battery percentage next to its menu bar icon. null (the
default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.bluetooth` [#haus-menubar-controlcenter-bluetooth]

`null or boolean` · default `null` · e.g. `true`

Whether the Bluetooth control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.displayBrightness` [#haus-menubar-controlcenter-displaybrightness]

`null or boolean` · default `null` · e.g. `true`

Whether the Screen Brightness control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.focus` [#haus-menubar-controlcenter-focus]

`null or boolean` · default `null` · e.g. `true`

Whether the Focus control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.nowPlaying` [#haus-menubar-controlcenter-nowplaying]

`null or boolean` · default `null` · e.g. `false`

Whether the Now Playing control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.menuBar.controlCenter.sound` [#haus-menubar-controlcenter-sound]

`null or boolean` · default `null` · e.g. `true`

Whether the Sound control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.bar [#hausbar]

The menu bar, and which pills it draws.

<div className="hf-optindex">
  [`aiUsage.provider`](#haus-bar-aiusage-provider) [`battery.hideOver`](#haus-bar-battery-hideover) [`bottom.enable`](#haus-bar-bottom-enable) [`bottom.items`](#haus-bar-bottom-items) [`bottom.items.agents`](#haus-bar-bottom-items-agents) [`bottom.items.aiUsage`](#haus-bar-bottom-items-aiusage) [`bottom.items.battery`](#haus-bar-bottom-items-battery) [`bottom.items.caffeinate`](#haus-bar-bottom-items-caffeinate) [`bottom.items.calendar`](#haus-bar-bottom-items-calendar) [`bottom.items.clock`](#haus-bar-bottom-items-clock) [`bottom.items.cpu`](#haus-bar-bottom-items-cpu) [`bottom.items.elgato`](#haus-bar-bottom-items-elgato) [`bottom.items.factory`](#haus-bar-bottom-items-factory) [`bottom.items.focus`](#haus-bar-bottom-items-focus) [`bottom.items.github`](#haus-bar-bottom-items-github) [`bottom.items.harvest`](#haus-bar-bottom-items-harvest) [`bottom.items.media`](#haus-bar-bottom-items-media) [`bottom.items.memory`](#haus-bar-bottom-items-memory) [`bottom.items.trill`](#haus-bar-bottom-items-trill) [`bottom.items.volume`](#haus-bar-bottom-items-volume) [`bottom.items.weather`](#haus-bar-bottom-items-weather) [`bottom.items.wifi`](#haus-bar-bottom-items-wifi) [`calendar.horizon`](#haus-bar-calendar-horizon) [`calendar.imminent`](#haus-bar-calendar-imminent) [`calendar.joinHosts`](#haus-bar-calendar-joinhosts) [`calendar.marquee`](#haus-bar-calendar-marquee) [`calendar.me`](#haus-bar-calendar-me) [`calendar.past`](#haus-bar-calendar-past) [`calendar.preciseUnder`](#haus-bar-calendar-preciseunder) [`calendar.refresh`](#haus-bar-calendar-refresh) [`calendar.upcoming`](#haus-bar-calendar-upcoming) [`calendar.width`](#haus-bar-calendar-width) [`clock.mode`](#haus-bar-clock-mode) [`clock.monoFont`](#haus-bar-clock-monofont) [`elgato.host`](#haus-bar-elgato-host) [`enable`](#haus-bar-enable) [`github.refresh`](#haus-bar-github-refresh) [`github.sources`](#haus-bar-github-sources) [`github.sources.*.ci`](#haus-bar-github-sources-ci) [`github.sources.*.command`](#haus-bar-github-sources-command) [`github.sources.*.icon`](#haus-bar-github-sources-icon) [`github.sources.*.limit`](#haus-bar-github-sources-limit) [`github.sources.*.org`](#haus-bar-github-sources-org) [`github.sources.*.search`](#haus-bar-github-sources-search) [`github.sources.*.severity`](#haus-bar-github-sources-severity) [`github.sources.*.title`](#haus-bar-github-sources-title) [`items`](#haus-bar-items) [`items.agents`](#haus-bar-items-agents) [`items.aiUsage`](#haus-bar-items-aiusage) [`items.battery`](#haus-bar-items-battery) [`items.caffeinate`](#haus-bar-items-caffeinate) [`items.calendar`](#haus-bar-items-calendar) [`items.claudeUsage`](#haus-bar-items-claudeusage) [`items.clock`](#haus-bar-items-clock) [`items.cpu`](#haus-bar-items-cpu) [`items.elgato`](#haus-bar-items-elgato) [`items.factory`](#haus-bar-items-factory) [`items.github`](#haus-bar-items-github) [`items.harvest`](#haus-bar-items-harvest) [`items.media`](#haus-bar-items-media) [`items.memory`](#haus-bar-items-memory) [`items.trill`](#haus-bar-items-trill) [`items.volume`](#haus-bar-items-volume) [`items.weather`](#haus-bar-items-weather) [`items.wifi`](#haus-bar-items-wifi) [`logo.color`](#haus-bar-logo-color) [`logo.gestures`](#haus-bar-logo-gestures) [`logo.icon`](#haus-bar-logo-icon) [`logo.size`](#haus-bar-logo-size) [`logo.status`](#haus-bar-logo-status) [`logo.sweep`](#haus-bar-logo-sweep) [`logo.updateCheck`](#haus-bar-logo-updatecheck) [`media.artworkTint`](#haus-bar-media-artworktint) [`media.collapse`](#haus-bar-media-collapse) [`media.icons`](#haus-bar-media-icons) [`media.marquee`](#haus-bar-media-marquee) [`media.width`](#haus-bar-media-width) [`position`](#haus-bar-position) [`widgets`](#haus-bar-widgets) [`widgets.<name>.command`](#haus-bar-widgets-name-command) [`widgets.<name>.enable`](#haus-bar-widgets-name-enable) [`widgets.<name>.icon`](#haus-bar-widgets-name-icon) [`widgets.<name>.interval`](#haus-bar-widgets-name-interval) [`widgets.<name>.permissions`](#haus-bar-widgets-name-permissions) [`widgets.<name>.placement`](#haus-bar-widgets-name-placement) [`widgets.<name>.script`](#haus-bar-widgets-name-script) [`widgets.<name>.style`](#haus-bar-widgets-name-style) [`workspaces.buried`](#haus-bar-workspaces-buried) [`workspaces.windows`](#haus-bar-workspaces-windows)
</div>

#### `haus.bar.aiUsage.provider` [#haus-bar-aiusage-provider]

`one of "latest", "claude", "codex", "opencode", "pi"` · default `"latest"` · e.g. `"claude"`

Which AI provider to display in the main pill: `latest` (default, automatically
shows whichever provider reported most recently), or one of
`claude`, `codex`, `opencode`, `pi`.
Clicking the pill always displays the full dropdown with all reporting providers.

Note this is about *usage readouts*, not about which client `scruff` 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`.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.battery.hideOver` [#haus-bar-battery-hideover]

`null or signed integer` · default `null` · e.g. `80`

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%).

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.enable` [#haus-bar-bottom-enable]

`boolean` · default `false` · e.g. `true`

Draw a SECOND bar along the bottom of the screen, at the same time as
the menu bar one. `haus.bar.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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.bottom.enable</span>
  </summary>

  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, `bar-bottom`. That name is also the
  CLI for it: `bar-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: windows carves the room out of its
  outer-bottom gap whenever this is on (with `haus.windows.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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items` [#haus-bar-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.bar.items` says about it —
so there is one switch per pill per bar and never two copies of the
same readout.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.bottom.items</span>
  </summary>

  Each value is `false` (not on this bar), one of `"left"`, `"center"`,
  `"right"` — the bar's three groups — or `true`, which is `"right"`:

  ```nix
  haus.bar.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.bar.items` extras (`cpu`, `memory`, `volume`,
  `calendar`, `caffeinate`, `trill`, `github`, `agents`, `aiUsage`,
  `elgato`, `harvest`), plus the Focus pill when `haus.focus.enable` is
  on. The whole left side
  (workspace pills, front app, the leader picker) and the tour stay on the
  menu bar.

  Needs `haus.bar.bottom.enable`; without it nothing here is drawn.
</details>

Example:

```nix
{
  agents = "left";
  clock = "right";
  media = "center";
}
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.agents` [#haus-bar-bottom-items-agents]

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

Described under [`haus.bar.items.agents`](#haus-bar-items-agents). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.aiUsage` [#haus-bar-bottom-items-aiusage]

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

Described under [`haus.bar.items.aiUsage`](#haus-bar-items-aiusage). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.battery` [#haus-bar-bottom-items-battery]

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

The battery pill.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.caffeinate` [#haus-bar-bottom-items-caffeinate]

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

Described under [`haus.bar.items.caffeinate`](#haus-bar-items-caffeinate). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.calendar` [#haus-bar-bottom-items-calendar]

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

Described under [`haus.bar.items.calendar`](#haus-bar-items-calendar). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.clock` [#haus-bar-bottom-items-clock]

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

The clock pill, pinned to the far right.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.cpu` [#haus-bar-bottom-items-cpu]

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

Described under [`haus.bar.items.cpu`](#haus-bar-items-cpu). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.elgato` [#haus-bar-bottom-items-elgato]

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

Described under [`haus.bar.items.elgato`](#haus-bar-items-elgato). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.factory` [#haus-bar-bottom-items-factory]

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

Described under [`haus.bar.items.factory`](#haus-bar-items-factory). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.focus` [#haus-bar-bottom-items-focus]

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

The Focus (Do-Not-Disturb) pill, drawn as a moon: a plain crescent while notifications are getting through, a crescent-and-stars on mauve while the Mac is quiet. It wore a bell until the `trill` pill above wanted one — a bell is what a notification IS, and two bells side by side (one struck) made the bar ask you to remember which was which, where every operating system that has ever shipped a Do-Not-Disturb switch has drawn it as a moon. Needs `haus.focus.enable`; setting this moves the pill but does not enable the Focus room by itself.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.github` [#haus-bar-bottom-items-github]

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

Described under [`haus.bar.items.github`](#haus-bar-items-github). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.harvest` [#haus-bar-bottom-items-harvest]

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

Described under [`haus.bar.items.harvest`](#haus-bar-items-harvest). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.media` [#haus-bar-bottom-items-media]

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

Described under [`haus.bar.items.media`](#haus-bar-items-media). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.memory` [#haus-bar-bottom-items-memory]

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

Described under [`haus.bar.items.memory`](#haus-bar-items-memory). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.trill` [#haus-bar-bottom-items-trill]

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

Described under [`haus.bar.items.trill`](#haus-bar-items-trill). haus declares both from one description, and this page prints it once.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.volume` [#haus-bar-bottom-items-volume]

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

Output volume / mute state.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.weather` [#haus-bar-bottom-items-weather]

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

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

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.bottom.items.wifi` [#haus-bar-bottom-items-wifi]

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

The Wi-Fi status pill.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.horizon` [#haus-bar-calendar-horizon]

`positive integer, meaning >0` · default `24` · e.g. `12`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.imminent` [#haus-bar-calendar-imminent]

`positive integer, meaning >0` · default `5` · e.g. `2`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.joinHosts` [#haus-bar-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:

```nix
[
  "meet.mycorp.example"
]
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.marquee` [#haus-bar-calendar-marquee]

`boolean` · default `true` · e.g. `false`

Sweep a long event title past while the pointer is on the calendar pill.

Hover only, exactly like the media pill's: the label is clipped to
`haus.bar.calendar.width` and hovering shows the rest. Off, the clip is
all you get on the pill and the timeline dropdown carries the full name,
which is where a title you actually need to read belongs anyway.

`haus.appearance.reduceMotion` turns this off as a default, along with
the rest of the motion haus draws.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.me` [#haus-bar-calendar-me]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It names you rather than a machine: your commit identity, the addresses that are yours, the account whose repositories this Mac works on. A desktop that set it would put its author's details on your work.

Addresses (or display names) that are YOU, dropped from the "with …"
line in the dropdown. An attendee list that includes you is a list that
tells you nothing — every meeting is "with you and Ana".

Usually unnecessary: a CalDAV account's calendar is named for the
address it syncs, so the pill takes the calendar names that look like
email addresses as its answer and re-checks them every six hours. Set
this when that guess misses — a local calendar, an alias you're invited
under, or a second address on the same account. It ADDS to what was
found rather than replacing it.

Example:

```nix
[
  "you@work.example"
]
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.past` [#haus-bar-calendar-past]

`positive integer, meaning >0` · default `24` · e.g. `8`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.preciseUnder` [#haus-bar-calendar-preciseunder]

`positive integer, meaning >0` · default `12` · e.g. `3`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.refresh` [#haus-bar-calendar-refresh]

`positive integer, meaning >0` · default `15` · e.g. `60`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.upcoming` [#haus-bar-calendar-upcoming]

`positive integer, meaning >0` · default `5` · e.g. `3`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.calendar.width` [#haus-bar-calendar-width]

`positive integer, meaning >0` · default `32` · e.g. `16`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.clock.mode` [#haus-bar-clock-mode]

`one of "full", "compact"` · default `"full"` · e.g. `"compact"`

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

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.clock.monoFont` [#haus-bar-clock-monofont]

`boolean` · default `true` · e.g. `false`

Whether the clock pill's date and time use `haus.fonts.mono.name`, like
the rest of Bar. Disable this to draw them in `haus.fonts.sans.name`
instead — macOS's system UI font by default, 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.

This pill is the only place in the BAR that reads
`haus.fonts.sans.name` — so within Bar the two options are really one
switch: this one chooses the family, that one says which. Elsewhere on
the machine that family also sets the text in pounce, perch and trill,
and this switch has nothing to say about those.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.elgato.host` [#haus-bar-elgato-host]

`string` · default `""` · e.g. `"elgato-key-light-mini-57a3.local"`

**Host-only.** A shared desktop may not set it; only your host file can. It names a device on your own network by host or IP, which is a fact about your desk. Left empty, the pill discovers the device itself, and that is what a shared desktop should leave it doing.

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/haus/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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.enable` [#haus-bar-enable]

`boolean` · default `false`

The SketchyBar menu bar. When off, the native macOS menu bar is kept
(hacker stops hiding it) and no bar is drawn.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.refresh` [#haus-bar-github-refresh]

`integer between 60 and 3600 (both inclusive)` · default `300` · e.g. `900`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources` [#haus-bar-github-sources]

`list of (submodule)` · see below

**Desktop-safe per key** (`submodule-list`). A list of settings, checked field by field inside each element — and a host that names the list at all REPLACES it rather than appending to it.

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.github.sources</span>
  </summary>

  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.
</details>

Example:

```nix
[
  { ci = true; }                                            # red default branches
  { search = "org:hausfold is:pr is:open"; }                 # the working queue
  {
    search = "org:hausfold is:pr is:open status:failure";    # …and the red half of it
    title = "red PRs";
    severity = "bad";
  }
]
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.ci` [#haus-bar-github-sources-ci]

`boolean` · default `false` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.command` [#haus-bar-github-sources-command]

`null or string` · default `null`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

A command run through `bash -c`, printing one row per line as
`<state>\t<text>` or `<state>\t<text>\t<url>`, where state is one rung
of the pill's ladder: `ok` (green), `busy` (sky — in flight, nobody's
turn yet), `warn` (peach — wants a human) or `bad` (red — reserved for
"this is broken", the tone a red default branch wears). The count is the
number of rows; a line that doesn't match the shape is dropped rather
than drawn mangled.

It runs from the bar's detached fetch, i.e. under launchd's environment
and not your interactive shell: name binaries by absolute path or expect
them missing. Host-only — a desktop may not set it, since it is
arbitrary code rather than data.

Example:

```nix
"gh api /notifications --jq '.[] | \"warn\\t\" + .subject.title'"
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.icon` [#haus-bar-github-sources-icon]

`string` · default `""` · e.g. `""`

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

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.limit` [#haus-bar-github-sources-limit]

`integer between 1 and 100 (both inclusive)` · default `8` · e.g. `5`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.org` [#haus-bar-github-sources-org]

`string` · default `""` · e.g. `"hausfold"`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.search` [#haus-bar-github-sources-search]

`null or string` · default `null` · e.g. `"org:hausfold is:pr is:open"`

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 hit total, 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.github.sources.*.search</span>
  </summary>

  Each row it draws carries its own MERGE VERDICT, from the same request
  that counted them: whether the pull request conflicts, what its head
  commit's checks came back as, and what the review landed on, folded into
  the one state GitHub's own merge box answers with. That is the row's
  glyph, its colour and the word after its title, and the worst of them
  across every source is what tints the pill's logo.

  In precedence order, first match wins: a draft is *grey* (its author
  already said "not ready", so its red checks are not news); then a
  conflict, then failed checks — both *peach*; then checks still running,
  *sky*, the machine's turn rather than yours; then changes requested,
  *peach* again; then green, *green*, with approved-and-green getting a
  glyph and a word of its own, being the one row that means you can press
  the button. Note that a run in flight outranks a reviewer's changes
  request rather than the other way round: peach and sky are two tiers,
  but the order the verdicts are tested in is one list. A mergeability
  GitHub has not computed yet reads as no verdict and resolves itself on
  the next refresh.

  Note what a search row cannot be: *red*, ever. Red on this pill means a
  DEFAULT BRANCH is broken — see `ci` — and nothing one pull request of
  yours does reaches it, because a red that fires for every
  work-in-progress is a red you stop reading. Marking the source itself
  `bad` (`severity`) doesn't change that either: it reddens this source's
  COUNT and its section heading, which is you putting your own filter on
  the same footing as a broken main, and leaves each row wearing the
  verdict it earned.

  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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.severity` [#haus-bar-github-sources-severity]

`null or one of "info", "warn", "bad"` · default \`\`bad`for a`ci`source,`info` for the others` · e.g. `"bad"`

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.

`bad` is worth spending carefully: a red default branch is the only
thing that turns this pill red on its own, so a source you mark `bad`
is one you are putting on that footing. A queue you merely want to
notice is `warn`.

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.github.sources.*.title` [#haus-bar-github-sources-title]

`string` · default `""` · e.g. `"red on main"`

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

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items` [#haus-bar-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`, the
doors `trill` and `github`, and the personal `agents`, `aiUsage`,
`elgato`, `harvest` — default false. Set
only what you want to change:

```nix
haus.bar.items = {
  weather = false;   # drop a default-on core pill
  cpu = true;        # add an off-by-default readout
  caffeinate = true; # add the keep-awake controller
};
```

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items</span>
  </summary>

  A pill set false is never created (its update script doesn't run either).
  The focus (Do-Not-Disturb) pill is separate — it rides
  haus.focus.enable, not this set. It can still be moved to the second bar
  with `haus.bar.bottom.items.focus`.

  `factory`, the merge-lease pill, is the one leaf here whose default is
  not fixed: it follows `haus.ai.enable`, the room that puts the `factory`
  binary on the machine. Set it either way to say so yourself.

  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.bar.bottom.items` mirrors these pills
  for the optional second bar, also accepts `focus`, and takes a side
  (`"left"` / `"center"` / `"right"`) rather than a bare bool; a pill named
  there moves down rather than being drawn twice.
</details>

Example:

```nix
{
  cpu = true;
  weather = false;
}
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.agents` [#haus-bar-items-agents]

`boolean` · default `false`

A pill tracking your agent windows, marked with a robot, one mark and a count per state: a filled `?` for the ones ready for your turn, an open ring for the ones working, a tick for the ones done. They sit in that order — urgency, left to right — a state with nothing in it draws nothing at all, and the robot takes the most urgent live state's colour, so the pill answers "is anything waiting on me" and "what else is running" in one glance rather than naming only the winner.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.agents</span>
  </summary>

  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 window's checkout is a `scruff` lane — its repo and PR status: merged, `+N unshipped` (exactly what `scruff 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 window, ⌥/right-click for a live peek at what it is saying (`zmx tail`) — except a desktop session, whose transcript is not readable from outside the app, so that row raises the app on either click. That peek is a popup the BAR summons, which is why the grants it needs are asked of the bar rather than of the terminal the first time you open one: Automation, for the System Events that plants the window's frame and, where `haus.terminal.floatOnTop` is on, the Ghostty that floats it above the tiling, and Accessibility, which macOS gates that frame drive on. Decline either and the peek still opens, it just lands wherever Ghostty last left a window, or sinks behind the first tiled window you click. Both are asked again after an update that moves the bar's own binary, which is a card in `haus permissions` rather than something breaking. 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, Codex's \~/.codex/hooks.json and pi's \~/.pi/agent/extensions/haus-agent-state.ts 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 — and a `scruff hook notify` entry appended beside yours on four events: Notification/Stop raise a trill banner when a lane blocks on you or finishes, UserPromptSubmit/PostToolUse take that banner back down once you have answered — as does simply focusing that lane's window, which the windows room reports — ARE declared, in terminal: they point at a haus-controlled path and self-heal on rebuild.) pi's extension is the one wiring that reports BOTH halves — the four states and the same `scruff hook notify` banners — because pi has no hook file to append a second command to. A row lives as labels on the window's own zmx session, so it disappears the moment that session does — which is what stands in for the session-end event Codex doesn't have. A session in Claude Code's DESKTOP app has no window of its own, so it is tracked by its conversation id instead and a click raises the app — every conversation there is a tab of the one window. Its row drops off when the session ends, and, since a force-quit fires no such event, whenever the app itself is not running. Subagents are deliberately not rows: they sit inside a session that already has one. Dormant until a client fires.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.aiUsage` [#haus-bar-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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.aiUsage</span>
  </summary>

  Where a plan caps a model family INSIDE the weekly window rather than beside it (a Max plan lets Fable take up to half the week, and gives nothing back for it), that family gets its own gauge in the dropdown, under the weekly it is carved out of. It stays in the dropdown and never reaches the pill's own label, which answers how close you are to being stopped: a spent ceiling is often the highest number on the machine and still changes nothing about what you can run next, since every other model is still there. Families are read from the account rather than named here, so a new ceiling shows up without a haus release. 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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.battery` [#haus-bar-items-battery]

`boolean` · default `true`

The battery pill.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.caffeinate` [#haus-bar-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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.calendar` [#haus-bar-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.bar.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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.calendar</span>
  </summary>

  For `haus.bar.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.bar.calendar.joinHosts` adds your own). LEFT-CLICK opens the day as a timeline — what's DONE in the last `haus.bar.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.bar.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.bar.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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.claudeUsage` [#haus-bar-items-claudeusage]

`boolean` · default `false`

Deprecated alias for `aiUsage`.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.clock` [#haus-bar-items-clock]

`boolean` · default `true`

The clock pill, pinned to the far right.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.cpu` [#haus-bar-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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.elgato` [#haus-bar-items-elgato]

`boolean` · default `false`

Toggles an Elgato Key Light on the local network. The light is found over mDNS (or pinned with `haus.bar.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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.factory` [#haus-bar-items-factory]

`boolean` · default `false`

The merge lease, beside the coffee pill: whether this Mac may land pull requests while nobody is watching, and how much of that grant is left. It draws the time remaining, an infinity sign for a lease that runs until you revoke it, and nothing at all beyond its own glyph when no lease stands, because everything queueing at "PR open" is the ordinary human-in-the-loop workflow rather than a fault.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.factory</span>
  </summary>

  Click for the grants: 4 hours, 12 hours, until revoked, and a Revoke button that goes solid red while there is authority to take back. RIGHT-CLICK revokes without opening anything. The one pill haus draws without being asked: it is on by default wherever `haus.ai.enable` is, because that room is what puts `factory` on this Mac and a Mac that has it has a lease to report. `haus.bar.items.factory = false` is how you keep the lease and lose the pill. The pill hides itself entirely on a Mac with no `factory` on PATH, which is any machine with `haus.ai.enable` off, and comes back on its own the moment that binary lands — so installing the tool by hand is enough, with no rebuild to remember. It reads `factory lease status --json` and writes nothing: the lease lives in a machine-local file no pull request can edit, which is the whole reason unattended merge authority is safe to grant at all. While one stands, `factory watchdog run` passes `factory shift` on a cadence and merges what the filter you typed can vouch for; `haus.ai.factory.enable` is what keeps that runner alive under launchd.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.github` [#haus-bar-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.bar.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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.github</span>
  </summary>

  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. The pill is TWO-TONE: the number is how many, coloured by the source it counts, and the octocat beside it is how bad, coloured by the worst single row anywhere — so five open PRs of which one conflicts reads as a neutral `5` under a peach logo, rather than as five bad things or as nothing wrong. The tones are one ladder: grey is no verdict (a draft, an issue), green is fine (and, approved-and-green, ready to press the button), sky is in flight (checks still running — the machine's turn), peach wants a human on one pull request (it conflicts, its checks came back red, or a reviewer asked for changes), and RED is reserved for a red DEFAULT BRANCH. Nothing one of your PRs can do turns the pill red, because a red that fires for every work-in-progress is a red you stop reading; the only other way to reach it is a source you declared `bad` yourself. Green is not a resting state: with nothing open there are no rows at all and the logo keeps the number's own grey, so a green octocat means "there is a queue, and every row in it is fine". 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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.harvest` [#haus-bar-items-harvest]

`boolean` · default `false`

A Harvest time-tracking pill; needs a \~/.config/sketchybar/harvest\_secrets.sh you provide. Click to stop the running timer or restart the last one. Like the Elgato pill it draws dim when Harvest can't be reached, keeping the label that names what was running — an API it can't ask is not the same thing as a timer that isn't running, and the two used to look identical.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.media` [#haus-bar-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).

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.media</span>
  </summary>

  Nothing moves in the corner of your eye on its own — a long title clips rather than scrolls, and hovering the pill is what sweeps it once, start to finish (`haus.bar.media.marquee`, which `haus.appearance.reduceMotion` turns off). 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.bar.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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.memory` [#haus-bar-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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.trill` [#haus-bar-items-trill]

`boolean` · default `false`

Opens trill's inbox — the notification compositor's history window — in one click, from a bar that is always on screen. It exists because the alternative doesn't work here: trill's own menu-bar item lives in macOS's menu bar, which `haus.bar.enable` hides (`_HIHideMenuBar`), so on a Mac running this desktop the inbox is only reachable by hover-revealing a bar that is meant to stay out of the way.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.items.trill</span>
  </summary>

  LEFT-CLICK opens the inbox, RIGHT-CLICK (or ⌥-click) opens it filtered to the asks — the questions parked on trill's ledge that are still waiting on you. The pill draws nothing at all when Trill.app isn't installed, because a control for an app you don't have is noise rather than an invitation; with the app installed but its daemon down it draws dim, which is the same distinction the elgato and harvest pills already make between "switched off" and "can't be reached". It carries no count yet: trill's inbox grew unread state in hausfold/trill#25, but no CLI verb reads it back out, and a badge computed by reading another app's SQLite behind its back is exactly the kind of claim that rots when the schema moves.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.volume` [#haus-bar-items-volume]

`boolean` · default `false`

Output volume / mute state.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.weather` [#haus-bar-items-weather]

`boolean` · default `true`

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

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.items.wifi` [#haus-bar-items-wifi]

`boolean` · default `true`

The Wi-Fi status pill.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.color` [#haus-bar-logo-color]

`null or one of "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `null` · e.g. `"teal"`

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.bar.logo.status` paints over it
while something needs attention, and the hover sweep runs from it and
returns to it.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.gestures` [#haus-bar-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 palette (⌘Space), which is what a bare click on this pill used to do                                                    |

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.logo.gestures</span>
  </summary>

  All three are drawn by the **launcher**, so all three need
  `haus.launcher.enable` (which the hacker desktop turns on). With the
  launcher 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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.icon` [#haus-bar-logo-icon]

`string` · default `""` · e.g. `"⌂"`

The glyph in the far-left logo pill. Any single character your bar
font can draw; the default is Nerd Font's `nf-fa-home` (`U+F015`), a
solid house.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.logo.icon</span>
  </summary>

  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.bar.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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.size` [#haus-bar-logo-size]

`positive integer, meaning >0` · default `20` · e.g. `25`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.status` [#haus-bar-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.bar.logo.updateCheck`) |
| `red`    | something haus runs is enabled but not running                      |

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.logo.status</span>
  </summary>

  Red is the one that matters. It is the same check `haus doctor` opens
  with — `nix-daemon`, plus each of AeroSpace / SketchyBar / pounce whose
  launchd job exists on this machine — and its whole point is that a
  wedged agent is otherwise invisible: the bar keeps drawing the last
  frame it painted, so a dead SketchyBar and a quiet one look identical.
  All of it is local, costs four `pgrep`s on a five-minute tick, and
  makes no network call.

  Yellow ranks below red and both outrank the accent, so the pill always
  shows the worst thing true about the machine.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.sweep` [#haus-bar-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 → scruff →
perch → trill → pounce → hacker) — 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.logo.sweep</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.logo.updateCheck` [#haus-bar-logo-updatecheck]

`boolean` · default `false` · e.g. `true`

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.bar.logo.status` is on.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.media.artworkTint` [#haus-bar-media-artworktint]

`boolean` · default `false` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.media.collapse` [#haus-bar-media-collapse]

`boolean` · default `false` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.media.icons` [#haus-bar-media-icons]

`attribute set of string` · default `{ }`

**Desktop-safe per key** (`attrs-of-string`). Keys and values are strings carrying no quote, backslash, `$`, backtick, newline or tab, because they are written into a generated file as shell assignments.

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.media.icons</span>
  </summary>

  This exists because of one hard limit: &#x2A;*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:

  ```nix
  haus.bar.media.icons."browser.video" = "󰗃";
  ```
</details>

Example:

```nix
{
  "browser.video" = "󰗃";
  "com.apple.podcasts" = "󰦔";
}
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.media.marquee` [#haus-bar-media-marquee]

`boolean` · default `true` · e.g. `false`

Sweep a long track title past while the pointer is on the media pill.

Hover is the only thing that starts it — nothing here runs a marquee on
a track change or on a timer — and one hover buys one full pass back to
the start. Off, a title too long for `haus.bar.media.width` is simply
clipped, and the dropdown still carries it in full, so nothing is lost
but the movement.

`haus.appearance.reduceMotion` turns this off as a default, along with
the rest of the motion haus draws.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.media.width` [#haus-bar-media-width]

`positive integer, meaning >0` · default `32` · e.g. `16`

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

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.position` [#haus-bar-position]

`one of "top", "bottom", "auto"` · default `"top"` · e.g. `"auto"`

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.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets` [#haus-bar-widgets]

`attribute set of (submodule)` · default `{ }`

**Desktop-safe per key** (`widget-entries`). Keys are plain widget names, because each becomes a SketchyBar item name; a desktop may place and retune a pill, but `command` stays host-only so it can never add one that runs code.

Every pill on the bar, including the bundled ones — the open form of
`haus.bar.items`, and the only way to add a pill haus does not ship.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.widgets</span>
  </summary>

  A widget is one script, run on a timer, whose output is the pill's
  label. Nothing else is required:

  ```nix
  haus.bar.widgets.backup = {
    command = "/etc/haus/backup-status.sh";
    interval = 300;
    icon = "󰁯";
  };
  ```

  The sixteen bundled pills are already declared here, so
  `haus.bar.widgets.clock`, `.media`, `.agents` and the rest exist on
  every machine and can be retuned by name — `interval` and `placement`
  are the two fields that mean something on all of them:

  ```nix
  haus.bar.widgets.weather.interval = 1800;   # ask half as often
  haus.bar.widgets.cpu.placement = "left";    # only on the bottom bar
  ```

  A bundled widget's `command` is haus's own plugin and is read-only —
  setting one is refused by name rather than silently ignored, because a
  pill whose script you replaced but whose dropdown, click gestures and
  colour rules still belong to haus is not a pill either of us can
  reason about. Write your own widget under a new name instead.

  `haus.bar.items.<name>` is sugar for `haus.bar.widgets.<name>.enable`
  and `haus.bar.bottom.items.<name>` for `.placement`; both keep working
  exactly as they did, and either spelling may be used. Setting the same
  pill both ways is not an error — the open form is the more specific of
  the two and simply wins, so `haus.bar.items.cpu = false` alongside
  `haus.bar.widgets.cpu.enable = true` draws the pill.

  `permissions` is a DECLARATION, not a grant: it says what macOS will
  ask your widget for, so a widget can be read for what it reaches for
  before it is switched on. Nothing here requests anything — what it
  does buy is a card per grant in `haus permissions` for as long as the
  pill is drawn, which is the one place a person is told why the bar is
  asking.
</details>

Example:

```nix
{
  backup = {
    command = "/etc/haus/backup-status.sh";
    icon = "󰁯";
    interval = 300;
    permissions = [
      "full-disk-access"
    ];
    placement = "right";
  };
}
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.command` [#haus-bar-widgets-name-command]

`null or string` · default `null` · e.g. `"/etc/haus/backup-status.sh"`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

The script whose output is this pill's label, run every
`interval` seconds. One line on stdout is the label; nothing at all
hides the pill for that tick, which is how a widget says "no news"
without drawing an empty box.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.widgets.<name>.command</span>
  </summary>

  It runs as you, with SketchyBar's own variables in the
  environment (`$NAME` is this widget's item id, `$BUTTON` and
  `$SENDER` on a click). Reach the bar back through `$SB` after
  sourcing `~/.config/sketchybar/bar.sh` rather than by naming
  `sketchybar`, so a widget moved to the bottom bar keeps talking to
  the bar it is actually on — see AGENTS.md, it is the single most
  common way a pill silently stops updating.

  null on a bundled pill, whose behaviour is haus's own plugin, and
  setting one there is an error rather than an override.

  The other tier is `script`, and a widget is one or the other:
  setting both is an error, since only one of them can be what the
  bar runs.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.enable` [#haus-bar-widgets-name-enable]

`boolean` · default `true`

Whether to draw this pill. A widget you declared is on by default —
you wrote it down to get it — while the bundled pills keep the
defaults they always had, which `haus.bar.items` is the sugar for.

Off is not "hidden": the pill is never created and its command
never runs, so a widget switched off costs nothing at all.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.icon` [#haus-bar-widgets-name-icon]

`string` · default `""` · e.g. `"󰁯"`

The glyph drawn to the left of the label, in the bar's own font
(`haus.fonts.mono.name`), so any Nerd Font glyph works. Empty
draws no icon and gives the label the whole pill.

Ignored on a bundled pill: those set their own, and several change
it to say something (the memory pill's pressure colour, the
github pill's two-tone logo). Ignored on a `script` widget for the
same reason — it draws its own — and setting it on one is an error
rather than a glyph that never appears.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.interval` [#haus-bar-widgets-name-interval]

`null or (positive integer, meaning >0)` · default `null` · e.g. `300`

How often to run `command`, in seconds. Works on the bundled pills
too, where it retunes what that pill already polls at:

```nix
haus.bar.widgets.weather.interval = 1800;
```

null keeps the widget's own default — for a bundled pill the rate
it ships with, for a `script` widget the `interval` in its own
header, and for a `command` widget a 60-second tick. A few pills are
push-driven rather than polled (`agents`) and a couple own
their rate through an older option of their own
(`haus.bar.calendar.refresh`); setting this on one of those is
accepted and changes only the backstop tick, which is exactly what
update\_freq is on a pill that repaints on an event.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.permissions` [#haus-bar-widgets-name-permissions]

`list of (one of "accessibility", "automation", "calendar", "contacts", "full-disk-access", "location", "microphone", "network", "photos", "reminders", "screen-recording")` · default `[ ]`

What macOS will ask this widget for the first time it runs.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.widgets.<name>.permissions</span>
  </summary>

  A DECLARATION: haus requests none of these, and listing one
  neither grants it nor makes the pill wait for it. What it buys is
  a widget that can be READ for what it will reach for before you
  switch it on — the pill you installed from someone else most of
  all. That is the same shape nebelung's ports metadata uses, one
  room over: the declaration lives with the thing, the consumer
  reads it.

  The consumer here is the manual-click deck. A pill that is drawn
  gets one card per grant in `haus permissions`, and `haus doctor`
  reports them, so declaring a grant is what puts the sentence
  explaining it in front of a person — and declaring one the widget
  does not use is a card that wastes their time.

  `network` is not a macOS grant at all, and is here because "this
  pill talks to the internet" is the property people actually want
  to see on a widget they didn't write.
</details>

Example:

```nix
[
  "full-disk-access"
]
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.placement` [#haus-bar-widgets-name-placement]

`null or one of "menu-bar", "bottom-left", "bottom-center", "bottom-right", "left", "center", "right"` · default `null` · e.g. `"bottom-left"`

Which bar this pill sits on, and where along it.

`"menu-bar"` is the top bar, and is where every pill goes unless
something says otherwise — it has one group to offer, because its
left is the workspace pills and its centre is the notch.
`"bottom-left"`, `"bottom-center"` and `"bottom-right"` are the
second bar's three groups, and need `haus.bar.bottom.enable`.

A bare `"left"` / `"center"` / `"right"` is the same three groups
of the bottom bar, spelled the way `haus.bar.bottom.items` spells
them — that option is sugar for this field, so both spellings
reach the same place.

null means "wherever the sugar put it", which on a machine that
never mentions this pill is the menu bar.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.script` [#haus-bar-widgets-name-script]

`null or absolute path` · default `null` · e.g. `./my-widget.sh`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

A barlib FRAMEWORK widget — the richer tier, and the one haus's own
pills are written in. Where `command` is a script whose stdout is a
label, this is a file that sources
`~/.config/sketchybar/barlib.sh`, defines `fetch`/`render` and
whatever `on_*` handlers it wants, and ends with
`barlib_main "$@"`. It gets everything a bundled pill gets: the
state diff (a quiet tick costs zero traffic), the tone ladder, the
identity marks, graphs, the dropdown row kinds, and one
batched call per repaint. The whole contract — the header keys,
the components, the tones, the dropdown row kinds — is
\<hausfold.co/docs/haus/rooms/bar-widgets>, and
`~/.config/sketchybar/barlib.sh` on this machine is the runtime it
documents.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.widgets.<name>.script</span>
  </summary>

  The file's own `# widget:` header is the WIRING — how often it
  ticks, what events it hears, whether it has a dropdown, whether it
  draws a graph — and it is read at build time, so a header key that
  does not exist fails the build naming your file rather than
  wiring nothing. The header is the reason this is a path and not a
  string: the file has to be readable while the configuration is
  being evaluated, and it is installed to
  `~/.config/sketchybar/widgets/<name>.sh`, which is where you go to
  run it by hand when it misbehaves — just the path, since the file
  names its own `BAR_ITEM` and the bar's own `$NAME` is what wins
  when the bar is the caller.

  `icon` is not read for one of these — a framework widget draws its
  own icon in `render`, which is the point of the tier — and the
  static half of its look is `style`.

  null on a bundled pill, whose script is haus's own, and setting one
  there is an error rather than an override.
</details>

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.widgets.<name>.style` [#haus-bar-widgets-name-style]

`attribute set of string` · default `{ }`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

The static half of a `script` widget's look: SketchyBar item
properties, set once when the pill is created, as a plain
`property = value` set.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.bar.widgets.<name>.style</span>
  </summary>

  It is the one thing a framework widget cannot say in its own file,
  and the reason is worth knowing before you reach for it: everything
  a pill draws that MEANS something — its label, its colour, whether
  it is showing at all — is the widget's `render`, which names a tone
  from the ladder and never a colour. What is left over is IDENTITY,
  which the ladder deliberately has no rung for: this widget's own
  hue, its padding, a font that is not the bar's. That is what
  belongs here, and a `style` doing more than that is usually a
  `render` that should have been doing it.

  One property is not optional: a widget whose header declares
  `graph` must name a `graph.color` here, because the line's colour
  is which readout it is and nothing else on the machine can answer
  that.

  Values are written into the bar's generated item file as they
  stand, so a palette name from `colors.sh` (`$TEAL`, `$SURFACE0`)
  reaches the theme, and anything with a space in it needs its own
  quotes — `"${config.haus.fonts.mono.name}:Bold:13"`. Host-only for
  exactly that reason: it is a shell fragment rather than data.

  Ignored on a `command` widget, and setting it on one is an error:
  the simple tier draws the same pill every other pill wears, and
  wanting to change that is what the framework tier is for.
</details>

Example:

```nix
{
  "background.padding_left" = "8";
  "icon.color" = "$TEAL";
}
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.workspaces.buried` [#haus-bar-workspaces-buried]

`boolean` · default `true` · e.g. `false`

Whether the bar names a floating window that has sunk behind the tiled
ones. AeroSpace floats a window out of the tiling grid but keeps it in
the ordinary stacking order, so the first tiled window you click covers
it, and nothing else on screen says it is there.

While a floating window on the focused workspace is less than a quarter
visible, a pill after the workspace pills shows its app's logo and
name, plus a count when there are more. Clicking it brings the window
back to the front. It needs the windows room, which measures what each
window has in front of it.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.bar.workspaces.windows` [#haus-bar-workspaces-windows]

`one of "dots", "count", "off"` · default `"dots"` · e.g. `"count"`

How a workspace pill shows the windows on it, once there is more than
one. `dots` draws a pip per window beside the workspace's glyph: filled
for a tiled window, hollow for one off the tiling grid (floating,
minimised, or belonging to a hidden app), which are the windows that
get lost. Past six windows it switches to the number. `count` always
draws the number, with a hollow ring after it when any window is off
the grid. `off` draws neither, and the pill says only that something
is there.

A workspace with pages counts them in, because its pill stands for all
of them. Click the pill you are on, or right-click any pill, for the
list of those windows. Choosing one focuses it, wherever it is.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

## Launcher [#launcher]

The command palette: its daemon, its commands, and every Pounce setting haus exposes.

### haus.launcher [#hauslauncher]

The ⌘Space command palette. Pounce is the app behind it.

<div className="hf-optindex">
  [`autoQuit.delay`](#haus-launcher-autoquit-delay) [`autoQuit.enable`](#haus-launcher-autoquit-enable) [`autoQuit.exclude`](#haus-launcher-autoquit-exclude) [`enable`](#haus-launcher-enable) [`fnKey`](#haus-launcher-fnkey) [`followSystemAppearance`](#haus-launcher-followsystemappearance) [`items`](#haus-launcher-items) [`items.<name>.alias`](#haus-launcher-items-name-alias) [`items.<name>.bundleIds`](#haus-launcher-items-name-bundleids) [`items.<name>.caption`](#haus-launcher-items-name-caption) [`items.<name>.hotkey`](#haus-launcher-items-name-hotkey) [`items.<name>.listed`](#haus-launcher-items-name-listed) [`items.<name>.workspaces`](#haus-launcher-items-name-workspaces) [`plugins`](#haus-launcher-plugins) [`scale`](#haus-launcher-scale) [`urlScheme.confirm`](#haus-launcher-urlscheme-confirm) [`urlScheme.enable`](#haus-launcher-urlscheme-enable) [`windowMode`](#haus-launcher-windowmode) [`windowSwitcher`](#haus-launcher-windowswitcher)
</div>

#### `haus.launcher.autoQuit.delay` [#haus-launcher-autoquit-delay]

`integer or floating point number between 0.25 and 3600 (both inclusive)` · default `2` · e.g. `5`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.autoQuit.delay</span>
  </summary>

  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.launcher.autoQuit.exclude rather than for a delay you would feel on
  every app.

  Read once, when auto-quit arms — changing it bounces the palette daemon
  on the next rebuild.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.autoQuit.enable` [#haus-launcher-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, the palette notices the last window go away and asks
the app to quit.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.autoQuit.enable</span>
  </summary>

  *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.launcher.autoQuit.exclude is for.

  Reads the same window snapshot as the ⌘Tab switcher, so it wants the
  same Accessibility grant (a one-time approval of the release app) 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 the palette'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 palette daemon, which haus does
  for you; nothing here needs a log-out to land.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.autoQuit.exclude` [#haus-launcher-autoquit-exclude]

`null or (list of string)` · default `the palette's own list — `\[ "com.apple.finder" ]\`\`

Bundle ids never auto-quit. `null` leaves the palette'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 palette
daemon on the next rebuild, so the app stops being quit immediately
rather than at the next log-in.

Example:

```nix
[
  "com.apple.finder"
  "com.docker.docker"
  "com.spotify.client"
]
```

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.enable` [#haus-launcher-enable]

`boolean` · default `false`

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

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.fnKey` [#haus-launcher-fnkey]

`one of "tap", "remap"` · default `"tap"` · e.g. `"remap"`

How the palette gets the Fn/Globe key when an item binds it — which
haus does by default, with haus.launcher.items."mode:emoji".hotkey = "fn".

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.fnKey</span>
  </summary>

  `tap` reads Fn with an event tap. It needs Pounce's Accessibility grant,
  and it SHARES the key with macOS: HIToolbox carries its own Globe handler
  inside every process, below the event stream a tap can see, so macOS's
  Emoji & Symbols picker can still open alongside the palette's. Setting System
  Settings ▸ Keyboard ▸ "Press 🌐 key to" to Do Nothing helps, but that
  value is read from login-session state — it wants a full logout, and even
  then the two handlers are racing rather than one owning the key.

  `remap` takes Fn away at the HID layer instead: it becomes F19, which
  the palette binds like any ordinary key. No Accessibility grant for this
  binding, and nothing left for macOS to race, because there is no Fn key
  in the system any more. That last part is also the cost — Fn stops being
  Fn EVERYWHERE, so no Fn+arrows (Home/End/PageUp/PageDown), no Fn+Delete,
  no Fn+F1–F12. Worth it on a Mac where Fn is only ever the emoji key; a
  bad trade on one where Fn+arrows is muscle memory.

  Three things `remap` does not promise:

  * F19 is a real key on the full-size Magic Keyboard (the one with a
    numeric keypad), where pressing it fires the binding too.
  * A keyboard that re-enumerates — an external one replugged, some
    sleep/wake cycles — drops the mapping, and the binding stays dead
    until the palette daemon restarts. `pounce doctor` reports it.
  * If F19 is already taken, or the keyboard doesn't expose Fn to IOHID,
    the palette undoes the remap and falls back to the tap — Accessibility
    grant and all.

  On haus the mapping is declared rather than left to the daemon
  (modules/launcher/default.nix says why: nix-darwin writes IOKit's
  UserKeyMapping whole, so a rebuild would otherwise drop a mapping the palette
  installed). It shares that list with the Caps Lock leader's remap, is
  re-applied at each activation, and does not survive a reboot — but it
  DOES outlive the daemon, so Fn stays remapped and inert while the palette is
  stopped. `pounce doctor` reports which of the two mechanisms is actually
  carrying the key.

  Inert unless something binds `fn`: with no such item, this is a key
  the palette reads and does nothing with. On a default haus that item is the
  emoji grid and Fn is its ONLY key — the Caps-Lock leader dropped `e` once
  one key did the job — so a host that turns the Fn binding off wants to
  put mode:emoji on something else.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.followSystemAppearance` [#haus-launcher-followsystemappearance]

`boolean` · default `true`

Let the launcher follow macOS Light/Dark Mode instead of pinning one
polarity: it 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 the launcher 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 the launcher to the flavor like every other port, which is exactly
what it did before this option existed.

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items` [#haus-launcher-items]

`attribute set of (submodule)` · default `{ }`

**Desktop-safe per key** (`launcher-items`). Keys are palette addresses — `cmd:<id>`, `app:<path>`, `setting:<pane>[?<anchor>]` or `mode:<name>`, spelled as `modules/launcher/item-grammar.nix` spells them — because each one names a row pounce already has, and carrying no quote, backslash, `$`, backtick, newline or tab, because the key is written out beside the values it configures. `shortcut:<uuid>` is the one shape a desktop may not name: it identifies one entry in one Mac's Shortcuts library, the same reason a display UUID is host-only.

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, or list it only where it is useful (`workspaces` / `bundleIds`).

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.items</span>
  </summary>

  ```text
  "cmd:<id>"                       a command, by script name without .sh
  "app:/Applications/Foo.app"      an application, by path
  "shortcut:<uuid>"                a Shortcuts-library entry, by the id
                                   `shortcuts list --show-identifiers` prints
  "mode:<name>"                    a built-in window — launcher, clipboard,
                                   emoji, screenshots, camera, filesearch
  ```

  Those keys are the palette'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:

  ```nix
  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". By default it is read with an event tap, which
  needs Pounce's Accessibility grant (unlike a Carbon chord or leader
  sequence) and fires only when Fn is tapped alone — and which SHARES the
  key with macOS's own Globe action, so the system emoji picker can still
  open alongside the palette's. haus.launcher.fnKey = "remap" is the way to own
  the key outright; read that option before reaching for it, because it
  costs Fn's other jobs. haus uses Fn for mode:emoji by default; set that
  item's hotkey to null to leave the Globe key to macOS — but Fn is the ONLY
  key haus gives the emoji grid. The Caps-Lock leader carried it on `e`
  until the Fn tap made that second binding redundant (`e` is unbound now,
  and `f` is Find Files), so nulling this leaves ⌘Space → "emoji" as the
  only route. Give mode:emoji another hotkey in the same breath if you want
  a key for it.

  Sequences are worth knowing about on a tiling desktop: they open a namespace
  that structurally can't collide with the ⌥/⌘ chords windows already claims,
  and they need no Accessibility grant (the palette 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 the palette warns about that itself when the daemon starts, and
  `pounce doctor` lists any binding that failed to arm.
</details>

Example:

```nix
{
  "app:/Applications/Ghostty.app" = {
    hotkey = "opt+t";
  };
  "cmd:brew-services" = {
    listed = false;
  };
  "cmd:emoji" = {
    alias = "emo";
    hotkey = "opt+e";
  };
  "cmd:lane-here" = {
    workspaces = [
      "T"
    ];
  };
  "cmd:peek" = {
    bundleIds = [
      "com.mitchellh.ghostty"
    ];
  };
  "mode:clipboard" = {
    hotkey = "cmd+shift+v";
  };
  "shortcut:0ECC8F7A-3A52-467A-84C0-511CCE1CB9B7" = {
    alias = "shelf";
  };
}
```

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items.<name>.alias` [#haus-launcher-items-name-alias]

`null or string` · default `null` · e.g. `"emo"`

A search shorthand, matched at a bonus over the item's real name —
so "emo" can find the Emoji Picker without renaming it.

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items.<name>.bundleIds` [#haus-launcher-items-name-bundleids]

`list of string` · default `[ ]`

List this row only while one of these apps is frontmost. Empty —
the default — means any app.

The tighter twin of `workspaces` for a row that needs a
particular app rather than a particular page: a Ghostty window
dragged onto another page still satisfies this one, and a
browser parked on a terminal page does not. Set both and the row
wants both. Case-insensitive.

Like `workspaces`, it scopes the row alone. Scoping a KEY to an
app is a different mechanism with a different cost — the palette
has to consume the keystroke to do it — and haus writes those
itself (the ⌘↵ and ⌘N Ghostty taps).

Example:

```nix
[
  "com.mitchellh.ghostty"
]
```

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items.<name>.caption` [#haus-launcher-items-name-caption]

`null or string` · default `null` · e.g. `"Clipboard history"`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.items.<name>.caption</span>
  </summary>

  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.

  A `shortcut:<uuid>` key derives nothing better than "Shortcut":
  the name lives in your Shortcuts library, which no build can
  read. Give any shortcut you bind a key a caption.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items.<name>.hotkey` [#haus-launcher-items-name-hotkey]

`null or string or list of string` · default `null` · e.g. `"opt+space e"`

A global chord, or a leader sequence, that invokes this item
directly without opening the palette first. Modifier names follow
the palette's spelling: cmd/command/super/meta · opt/option/alt ·
ctrl/control · shift.

Whether the KEY name is one the palette 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, and how it is carried is
haus.launcher.fnKey: by default an Accessibility-gated event tap
that fires on a lone tap and SHARES the key with macOS's own
Globe action, or `remap`, which takes the key away from macOS
entirely at the cost of Fn's other jobs.

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items.<name>.listed` [#haus-launcher-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 the palette's own `enabled` key.)

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.items.<name>.workspaces` [#haus-launcher-items-name-workspaces]

`list of string` · default `[ ]`

List this row only while one of these workspaces is in front.
Empty — the default — means everywhere.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.items.<name>.workspaces</span>
  </summary>

  A bare name matches that page AND its children, the same rule
  the ⌃⇥ page walk's prefix uses: `"T"` covers T and every
  T/\<repo> lane page. `"T/*"` is the children only, and `"T/main"`
  is that one page. Case-insensitive.

  It scopes the ROW, never this item's `hotkey` — a key you bound
  stays bound, exactly as it does under `listed = false`. What
  this is FOR is a row whose command needs the window you were
  looking at: an agent lane or a shell "here" reads the focused
  terminal's directory, and from a browser it has nothing to read.

  Which page you are on is read from the workspace-recency file
  the windows room's AeroSpace hook maintains. **With no such file
  — no tiler, or the windows room off — this filters nothing**,
  deliberately: a machine that cannot answer the question would
  otherwise hide the row forever.
</details>

Example:

```nix
[
  "T"
]
```

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.plugins` [#haus-launcher-plugins]

`list of string` · default `[ ]`

Optional palette commands to install, by id. Each one assumes a
specific tool, service or app it cannot provision, so the whole set is
off until you name what you have:

```text
audio  bluetooth  caffeinate  docker  github
perplexity  spotify  ssh  tailscale
```

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.plugins</span>
  </summary>

  Enabling one installs the command AND the plain CLI it shells out to —
  bluetooth pulls blueutil, audio pulls switchaudio-osx, github pulls gh
  — so the command stops guarding "not found" with no separate install.
  The ones that want an app or a daemon instead (Spotify, a Docker
  engine, tailscaled) stay yours to provide; those guard at runtime with
  an install hint.

  The ids are pounce's own `optional/` filenames without .sh, not a
  vocabulary haus invents on top. A typo fails the build naming the set
  that exists, which is why this is a plain list rather than an enum
  haus would have to keep in step with pounce's lock.

  This is the whole of what a "pack" is here, and the other two halves
  already exist: haus's own commands are gated by the feature that owns
  them (`bench-lane` by haus.developer.enable, `gh-dash` by
  haus.terminal.ghDash.enable, the lane commands by the AI room), and
  haus.launcher.items.\<addr>.listed = false hides a row you would rather
  reach only by key.
</details>

Example:

```nix
[
  "docker"
  "tailscale"
]
```

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.scale` [#haus-launcher-scale]

`integer or floating point number between 0.8 and 2.0 (both inclusive)` · default `haus.ui.scale, held inside the palette's 0.8-2.0` · e.g. `1.4`

How big the palette is drawn. Multiplies every size in its 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.scale</span>
  </summary>

  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.

  The palette'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.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.urlScheme.confirm` [#haus-launcher-urlscheme-confirm]

`boolean` · default `true`

Ask on screen before a `pounce://` link RUNS something — a command, a
Shortcut, an application, or the camera window, which starts a capture
session rather than drawing a list. The sheet names the app that opened
the link and every argument it carries, because the arguments are the
half somebody else wrote. A link that only OPENS something — clipboard
history, the emoji picker, a System Settings pane — is never confirmed.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.urlScheme.confirm</span>
  </summary>

  On by default, and it is the one place pounce trusts your keyboard more
  than its caller: a hotkey is you, while a URL can be written into a page,
  an email or a shared note, and the Apple Event names the app that OPENED
  the link rather than whoever wrote it.

  Set false to trust a link exactly as much as a hotkey. The command's own
  `confirm =` header still gets its sheet — that is `pounce run`'s
  contract — and nothing else does. Worth doing on a machine whose links
  all come from your own notes, where a second sheet behind your note
  app's own "open this link?" is a keystroke you stop reading.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.urlScheme.enable` [#haus-launcher-urlscheme-enable]

`boolean` · default `true`

Let a link run a palette item: `pounce://run?item=cmd:<id>&arg=<value>`,
the same item keys `pounce run` takes. It is how something that can only
produce a LINK — a row in an Obsidian base, a line in a note, a
spreadsheet cell, a Shortcut, a web page — reaches a command with no
plugin of its own to shell out for it.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.urlScheme.enable</span>
  </summary>

  `arg` is repeatable (up to four) and positional: the script reads them as
  `$1`, `$2` and so on, nothing crosses a shell, and only a `cmd:` item
  takes any.

  The scheme itself is claimed by Pounce.app's bundle and cannot be
  unclaimed at runtime, so this is the switch that actually closes the
  door: with it false the daemon refuses every link and says so in a
  banner. Set it false on a machine where a link arriving from a page or
  an email is not something you want reaching the palette at all.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.windowMode` [#haus-launcher-windowmode]

`one of "default", "compact"` · default `"default"`

The palette's proportions. `default` is the palette's roomier layout,
which shows the top results the moment it opens — pounce's own default,
and haus's. `compact` is narrower with tighter rows and keeps its list
hidden until you type; it also turns off the Stage, whose tiles are
exactly the "something on an empty query" compact exists to avoid.

This is shape, not size: how BIG the palette is drawn is
haus.launcher.scale. The two compose — a compact palette at scale 1.4
is still the compact layout, just readable from further away.

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

#### `haus.launcher.windowSwitcher` [#haus-launcher-windowswitcher]

`boolean` · default `true`

Replace the stock ⌘Tab app switcher with the palette'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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.launcher.windowSwitcher</span>
  </summary>

  Because the tiler is running 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.

  Pages count as the workspace they belong to: `T`, `T/haus` and `T/main`
  are one place, so walking pages with ⌃⇥ and then tapping ⌘⇥ takes you
  off `T` altogether rather than back to the page you just left. Getting
  between pages is the ⌃⇥ walk's job, the same division of labour as
  windowNav's focus keys above.

  Needs the daemon to hold an Accessibility grant. The daemon runs the
  release app (whose team-anchored signing requirement is what keeps the
  grant alive across rebuilds), so on a haus machine this is a one-time
  approval, not a rebuild-by-rebuild ritual. 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.
</details>

<small>
  Declared in 

  [`modules/launcher/options.nix`](https://github.com/hausfold/haus/blob/main/modules/launcher/options.nix)

  .
</small>

## Shelf [#shelf]

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

### haus.shelf [#hausshelf]

The notch file shelf. Perch is the app behind it.

#### `haus.shelf.enable` [#haus-shelf-enable]

`boolean` · default `false`

The perch notch file shelf, installed from its own flake (copied to
/Applications, with its `perch` command line tool linked onto PATH).

<small>
  Declared in 

  [`modules/shelf/options.nix`](https://github.com/hausfold/haus/blob/main/modules/shelf/options.nix)

  .
</small>

#### `haus.shelf.followSystemAppearance` [#haus-shelf-followsystemappearance]

`boolean` · default `true`

Let the shelf's palette follow macOS Light/Dark Mode instead of pinning
one polarity: the shelf 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 launcher option of the same name: with
this on, the shelf 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.

The shelf has no theme picker of its own — it is a five-second
surface with nowhere to put one — so this is the only word on its
colors.

<small>
  Declared in 

  [`modules/shelf/options.nix`](https://github.com/hausfold/haus/blob/main/modules/shelf/options.nix)

  .
</small>

#### `haus.shelf.watchScreenshots` [#haus-shelf-watchscreenshots]

`boolean` · default `config.haus.shelf.enable`

Set this Mac up so new screenshots reach the shelf on their own.
On with the shelf, off without it — and nothing here happens at all
unless `haus.shelf.enable` is on, whatever this says.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.shelf.watchScreenshots</span>
  </summary>

  It does NOT hand perch the folder — it cannot. A watched folder is a
  security-scoped bookmark, and only perch itself can mint one, out of a
  panel you clicked; nothing written from outside the sandbox is a grant.
  What this removes is every OTHER obstacle between a capture and the
  shelf:

  * The floating preview thumbnail goes away
    (`haus.screenshots.thumbnail`, at `mkDefault`, so naming that option
    in your host puts it back). The thumbnail is not a preview of a saved
    file: macOS HOLDS the capture in the corner and writes it out only
    when the thumbnail expires (about five seconds) or you dismiss it, so
    a watched folder catches every screenshot five seconds after you took
    it.

  * Where your screenshots go is written into
    `~/.config/perch/config.json`, so perch can offer to watch that
    folder by name instead of asking you to find it — macOS will not tell
    a sandboxed app where captures are saved, and this machine already
    knows. Only when `haus.screenshots.location` says where that is: with
    it unset, haus would be guessing, and perch falls back to the Desktop
    (macOS's own answer) by itself. A perch too old to know the key
    ignores it.

  Turn it off if you would rather keep the thumbnail's markup and drag
  affordances than have screenshots shelved.
</details>

<small>
  Declared in 

  [`modules/shelf/options.nix`](https://github.com/hausfold/haus/blob/main/modules/shelf/options.nix)

  .
</small>

## Notifications [#notifications]

How this desktop's own banners get drawn. haus has *drawn through* trill since `haus-notify` landed — finding it at runtime and falling back to Apple's banner when it isn't there — and that is unconditional, in ../core, whatever this room says. What lives HERE is the narrower question of whether haus installs and pins the bundle, at a fixed path its Full Disk Access grant can survive. Named for the subject rather than the app, like every other room since the 2026-08-16 sweep (../moved.nix). It also owns the one source haus itself feeds those banners from: `haus.mail`, an IMAP IDLE watcher that draws a card per new message.

### haus.notifications [#hausnotifications]

The notification compositor haus already draws through. Trill is the app behind it; this switch is whether haus owns the bundle. It is NOT where a banner's routing lives — that is `~/.config/trill/rules.json`, and haus deliberately puts no second dial in front of it.

#### `haus.notifications.compositor` [#haus-notifications-compositor]

`boolean` · default `false`

The trill notification compositor, installed from its own flake and
copied to a fixed `/Applications/Trill.app`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.notifications.compositor</span>
  </summary>

  haus has drawn its banners through trill since `haus-notify` landed, but
  by *finding* it at runtime — PATH, then `/Applications`, then
  `~/Applications` — and falling back to Apple's banner when nothing
  answered. That still works, is still the fallback, and is not gated on
  this switch. The bundle this room places wins that search against a
  hand-installed one, deliberately: a dev build left in `~/Applications`
  used to outrank it forever, because this room rewrites its own path on
  every activation and can never displace a copy sitting in front of it.
  What this adds is the other half: the bundle actually being there, at a
  path that does not move, pinned by this machine's flake lock like every
  other room.

  Why the path is fixed rather than a store path: trill's whole
  `trill doctor` and System Mirror surface is a **Full Disk Access** grant,
  and macOS keys a TCC grant per app *path* and signing identity. A store
  path changes on every version bump, so the grant would drop on the
  rebuild that installed the fix you wanted. `/Applications/Trill.app` is
  where a drag-install or a cask would have put it, so an existing grant
  carries over rather than being asked for again.

  Off by default, and not only out of taste: there is no `trill` cask and
  the `trill` command already resolves without this room, so turning it on
  is a decision to have haus own the bundle.

  It does **not** put `trill` on PATH — `modules/core/trill.sh` already
  does, for every install source including this one, and a second
  `bin/trill` would be a build-time collision rather than a redundancy.

  It does **not** decide what happens to any individual notification
  either. Routing, dropping and rewriting by `source` all live in
  `~/.config/trill/rules.json`, hot-reloaded, and haus deliberately puts
  no second dial in front of it.
</details>

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

### haus.mail [#hausmail]

Watch a mailbox over IMAP and draw a card per new message, pushed rather than polled. Beside `notifications` because a card is all it produces — and like that switch, it holds no filter of its own: your account's filters decide what arrives, `~/.config/trill/rules.json` decides what a `haus.mail` card then does.

<div className="hf-optindex">
  [`address`](#haus-mail-address) [`enable`](#haus-mail-enable) [`host`](#haus-mail-host) [`mailboxes`](#haus-mail-mailboxes) [`port`](#haus-mail-port) [`secretCommand`](#haus-mail-secretcommand)
</div>

#### `haus.mail.address` [#haus-mail-address]

`string` · default `""` · e.g. `"you@gmail.com"`

**Host-only.** A shared desktop may not set it; only your host file can. It names you rather than a machine: your commit identity, the addresses that are yours, the account whose repositories this Mac works on. A desktop that set it would put its author's details on your work.

The mailbox's own address, which is also the IMAP username.

It is the account name for the login and the account name in the link
the card's Open pill carries — Gmail's `u/<address>` form rather than
`u/0`, which means "whichever Google account signed in first" and is
the wrong mailbox on any Mac signed into two of them.

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

#### `haus.mail.enable` [#haus-mail-enable]

`boolean` · default `false`

Watch a mailbox over IMAP and draw one card per new message.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.mail.enable</span>
  </summary>

  The server pushes: an IDLE connection is held open, so a card arrives
  in the seconds after the mail does and nothing on this Mac polls. What
  draws it is `haus-notify`, so it is a trill card where trill is
  installed and an Apple banner where it is not, like everything else
  this desktop puts on screen.

  Which mail is worth a card is decided twice before haus sees it, and
  neither dial is this room's. The account's own filters decide what
  reaches the mailbox at all — a Gmail filter that skips the inbox never
  produces a card — and `~/.config/trill/rules.json` decides what a
  `haus.mail` card then does: quiet hours, dropped, rewritten, or
  silenced entirely. That is why there is no filter option here.

  It needs the mailbox's password before it will start (see
  `secretCommand`). Until there is one the agent parks with EX\_CONFIG and
  says so in `~/Library/Logs/haus-mail.log`; the build is fine, the
  watcher simply is not up.
</details>

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

#### `haus.mail.host` [#haus-mail-host]

`string` · default `"imap.gmail.com"` · e.g. `"imap.fastmail.com"`

The IMAP server.

Gmail's by default because that is the account this was built for, but
nothing in the room is Gmail-specific except one flourish: where the
server says it speaks Gmail's dialect, the card gets an Open pill that
goes to the message's own thread. Elsewhere the card carries no pill,
which is the honest answer — a link that opens the wrong thing is
worse than no link.

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

#### `haus.mail.mailboxes` [#haus-mail-mailboxes]

`list of string` · default `[ "INBOX" ]`

Which mailboxes to watch, spelled exactly as the server spells them.

⚠️ Do not empty this list to mean "all of them". The watcher's own
default for an empty list is every mailbox on the account, which on
Gmail is every label plus All Mail — so one arriving message announces
itself twice, three times, once per label it matched. haus refuses to
build with the list empty for that reason.

`INBOX` is the one name IMAP standardises. Everything else is the
provider's own spelling, and Gmail's are bracketed
(`[Gmail]/All Mail`); `haus-mail-announce` has no way to guess them, so
take them from your client or from
`goimapnotify -conf ~/.config/haus/mail/watch.json -list`.

Example:

```nix
[
  "INBOX"
  "[Gmail]/Starred"
]
```

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

#### `haus.mail.port` [#haus-mail-port]

`16 bit unsigned integer; between 0 and 65535 (both inclusive)` · default `993` · e.g. `143`

The IMAP port. 993 is implicit TLS, which is what this room configures
and what every hosted provider answers on.

An option because a self-hosted server can differ, not because anyone
should need to change it.

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

#### `haus.mail.secretCommand` [#haus-mail-secretcommand]

`string` · default `""`

**Host-only.** A shared desktop may not set it; only your host file can. It points at a secret, or at the store this machine keeps its secrets in, so it belongs to one person on one Mac.

Shell printing the mailbox's IMAP password on stdout.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.mail.secretCommand</span>
  </summary>

  A command rather than a value so the password never enters the Nix
  store, where it would be world-readable and kept in every generation.
  It is run at startup by the watcher and again by the announcer for each
  message it fetches, and nothing caches the answer to disk.

  EMPTY (the default) means haus holds it: this room declares
  MAIL\_IMAP\_PASSWORD to the secrets room, `haus-secret --check` asks you
  for it once, and `haus.secrets.provider` decides where it is kept. Set
  this only to fetch the value some other way — which withdraws the
  declaration, since a manifest entry nothing reads is a value the wizard
  would ask for and never use.

  On a Google account the value is an **app password**
  (`https://myaccount.google.com/apppasswords`, which needs 2-Step
  Verification on first): Google no longer accepts an account password
  for IMAP. It is a static credential with full access to the mailbox, so
  it belongs in the keychain and nowhere else — and revoking it in that
  same page is what stops this Mac reading the mail, without touching any
  other machine.

  ⚠️ A command containing `%s` is reinterpreted by the watcher as a
  format string (it substitutes the mailbox name into hooks that way), so
  keep percent signs out of it.
</details>

Example:

```nix
"op read op://private/gmail/app-password"
```

<small>
  Declared in 

  [`modules/notifications/options.nix`](https://github.com/hausfold/haus/blob/main/modules/notifications/options.nix)

  .
</small>

## Focus [#focus]

One quiet switch: Do Not Disturb, an optional status somewhere else, and your own hooks on both edges. `focus 25` puts a fuse on it — quiet now, quiet off again in twenty-five minutes, counting down on the bar — and the named states (`scenes`) around it are the same switch with more members, of which quiet is the built-in one. A scene can carry a `when` (a daily window, a network, the power source, the screens) and be entered for you, on a rule that never overrides a state you chose.

### haus.focus [#hausfocus]

One quiet switch: Do Not Disturb, optional Slack status, and your hooks — plus the named scenes around it.

<div className="hf-optindex">
  [`enable`](#haus-focus-enable) [`hooks`](#haus-focus-hooks) [`scenes`](#haus-focus-scenes) [`scenes.<name>.apps.closeOnExit`](#haus-focus-scenes-name-apps-closeonexit) [`scenes.<name>.apps.open`](#haus-focus-scenes-name-apps-open) [`scenes.<name>.audio.input`](#haus-focus-scenes-name-audio-input) [`scenes.<name>.description`](#haus-focus-scenes-name-description) [`scenes.<name>.dnd`](#haus-focus-scenes-name-dnd) [`scenes.<name>.hooks`](#haus-focus-scenes-name-hooks) [`scenes.<name>.key`](#haus-focus-scenes-name-key) [`scenes.<name>.preventSleep`](#haus-focus-scenes-name-preventsleep) [`scenes.<name>.restorePreviousState`](#haus-focus-scenes-name-restorepreviousstate) [`scenes.<name>.when.days`](#haus-focus-scenes-name-when-days) [`scenes.<name>.when.displays`](#haus-focus-scenes-name-when-displays) [`scenes.<name>.when.power`](#haus-focus-scenes-name-when-power) [`scenes.<name>.when.time`](#haus-focus-scenes-name-when-time) [`scenes.<name>.when.wifi`](#haus-focus-scenes-name-when-wifi) [`slack.enable`](#haus-focus-slack-enable) [`slack.snooze`](#haus-focus-slack-snooze) [`slack.statusEmoji`](#haus-focus-slack-statusemoji) [`slack.statusText`](#haus-focus-slack-statustext) [`slack.tokenCommand`](#haus-focus-slack-tokencommand) [`timers`](#haus-focus-timers) [`triggers.interval`](#haus-focus-triggers-interval)
</div>

#### `haus.focus.enable` [#haus-focus-enable]

`boolean` · default `false`

The focus room: one quiet switch — bar pill, palette command, and a
`focus` 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.focus.enable</span>
  </summary>

  The same switch generalises: `haus.focus.scenes.<name>` declares other
  named states (stay awake, this microphone, these apps, these hooks) and
  `focus scene <name>` enters one. Quiet is the built-in scene, and the
  one every surface above already means.

  Honest scope: focus 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.

  Two grants, both on Pounce.app: the keypress needs Accessibility, and
  reading the state back needs Full Disk Access. With the launcher room
  on, focus forwards both through the Pounce.app it runs, so that one
  pair of checkboxes covers the pill, the palette and the CLI, and it
  survives rebuilds. Without a launcher, whatever app invokes focus needs
  the grants itself: sketchybar for the pill, which TCC keys to the
  binary and asks for again after a rebuild moves it, and your terminal
  for the CLI. Without Full Disk Access the state is focus's own memory,
  which drifts when you toggle from Control Center or your phone. `focus
  doctor` says which route is live and what is left to grant.
</details>

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.hooks` [#haus-focus-hooks]

`list of (absolute path or string)` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

Extra scripts run on every switch, both ways, each called with a single
argument "on" or "off". Paths are copied into the store; strings are
run as-is (so $HOME paths work). Failures are logged, never fatal —
a broken hook can't wedge the toggle.

Example:

```nix
[ ./onair-light.sh "/Users/ada/bin/pause-music" ]
```

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes` [#haus-focus-scenes]

`attribute set of (submodule)` · default `{ }`

**Desktop-safe per key** (`scene-entries`). Keys are plain scene names — what you type after `focus scene`, so a key has to survive as one shell word.

Named machine states, entered with `focus scene <name>` and left with
`focus scene off`. One at a time: entering a scene leaves whichever was
running, so the Mac is only ever in one.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.focus.scenes</span>
  </summary>

  `quiet` is the built-in — it is what `focus on`, the bar pill and the
  palette command already enter — so the name is reserved and defining it
  here is an error. Shape quiet through `haus.focus.slack` and
  `haus.focus.hooks` instead.

  With the launcher room on, every scene is a palette row too — a
  generated `Scene: <name>` command, plus `Leave Scene`, each with a line
  on the cheatsheet's Palette Commands page — so entering one doesn't
  mean remembering its name in a terminal. Give a scene a `key` and it
  gets a leader binding and its cheatsheet row generated as well.

  A scene with a `when` is entered for you — a daily window, a set of
  weekdays, a Wi-Fi network, the power source, how many screens are
  attached. One rule governs all of it, and it is the reason this is
  safe to leave running: &#x2A;*the daemon never overrides a state you
  chose.** It enters a scene on the EDGE where the condition becomes
  true and only from a neutral Mac, it leaves only the scene it entered
  itself, and it never re-enters one you walked out of until that
  condition has gone false and true again. So a scene you leave at ten
  past nine stays left, and a scene you enter by hand is never taken
  away from you.
</details>

Example:

```nix
{
  recording = {
    description = "camera on, nothing interrupts";
    preventSleep = true;
    audio.input = "Studio Mic";
    apps.open = [ "OBS" ];
  };
}
```

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.apps.closeOnExit` [#haus-focus-scenes-name-apps-closeonexit]

`boolean` · default `false`

On exit, quit the apps this scene STARTED — the ones from
`apps.open` that weren't already running when you entered. An
app you already had open is not a lever the scene pulled, so
leaving never closes it; that is the same rule DND and the input
device follow, and it is what keeps a work mode from taking your
editor down with it.

The quit is the polite one (the same message ⌘Q sends), so an app
with unsaved work still gets to ask. It is recorded on entry, so
exit closes what it opened even if the scene has since been
edited out of the table.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.apps.open` [#haus-focus-scenes-name-apps-open]

`list of string` · default `[ ]`

Apps to launch on entry, by the name or bundle id `open -a`
takes. Exiting a scene leaves them running unless
`apps.closeOnExit` says otherwise — quitting an app you were
mid-sentence in is not a decision a config file should make by
default.

Example:

```nix
[
  "OBS"
  "Reminders"
]
```

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.audio.input` [#haus-focus-scenes-name-audio-input]

`string` · default `""` · e.g. `"Studio Mic"`

Switch the system input device on entry, by the exact name
`SwitchAudioSource -a -t input` prints. Put back by `focus scene
off` when the scene is what changed it — unlike DND there is no
"off" for an input device, so `restorePreviousState` doesn't
govern it. Naming a device that isn't plugged in logs and moves
on; the rest of the scene still applies.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.description` [#haus-focus-scenes-name-description]

`string` · default `""` · e.g. `"camera on, nothing interrupts"`

One line, shown by `focus scene list`. The scene's own name is the address.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.dnd` [#haus-focus-scenes-name-dnd]

`boolean` · default `true`

Turn Do Not Disturb on while this scene is. `false` means the
scene leaves DND exactly as it found it — not that it turns it
off.

Entering a DND scene runs the Slack leg and `haus.focus.hooks`
too, because it is the same quiet the bar pill and `focus on`
mean — but only when the scene is what makes the Mac quiet.
Entering one while already quiet changes nothing, so nothing
fires, and leaving it fires nothing either: the two edges stay
paired.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.hooks` [#haus-focus-scenes-name-hooks]

`list of (absolute path or string)` · default `[ ]` · e.g. `[ ./key-light.sh ]`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

Scripts run on entry and exit, each called with "on" or "off" —
the same contract as `haus.focus.hooks`, scoped to this scene.
Failures are logged, never fatal.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.key` [#haus-focus-scenes-name-key]

`null or string` · default `null` · e.g. `"r"`

A launch-mode key for this scene: tap the leader, then this key,
to enter the scene — and the same key again to leave it. The
cheatsheet row is generated beside the binding, so the key and
its caption cannot drift.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.focus.scenes.<name>.key</span>
  </summary>

  An AeroSpace key name, and a POSITION on a US keyboard like
  every other key in this config (`haus.keys.layout` moves the
  letters onto the keys that print them). It shares launch mode
  with the app roster, the numbered workspaces and their throws,
  the fixed actions (`v`, `f`, `z`, `,`, `.`, `` ` ``, `-`, `=`,
  `/`, esc and the arrows) and `haus.keys.leaderExtras`, so a key
  one of those already claims is refused at eval rather than
  silently shadowing it.

  Needs `haus.windows.enable` and `haus.keys.leader != "none"` —
  launch mode is the tiler's, and a machine that claims no leader
  has no launch mode to bind into. Without either, the scene keeps
  its palette row and loses only the key.
</details>

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.preventSleep` [#haus-focus-scenes-name-preventsleep]

`boolean` · default `false`

Hold a `caffeinate` assertion (display, disk, idle and system)
for as long as the scene is on. Released on exit. A reboot kills
the assertion and leaves only a stale pid file behind, which the
next entry clears — and the release checks the pid is still a
caffeinate before signalling it, so a reused pid is never the
one that gets killed.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.restorePreviousState` [#haus-focus-scenes-name-restorepreviousstate]

`boolean` · default `true`

On exit, put Do Not Disturb back the way it was before the scene
started rather than always turning it off. It has exactly one
case: you were **already quiet** when you entered. Left true,
leaving the scene keeps you quiet, because nothing asked to be
un-quieted. Set false, leaving always ends quiet-off.

It doesn't govern anything else — a scene only ever reverses a
lever it actually moved, so there is nothing else for "restore"
and "off" to disagree about.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.when.days` [#haus-focus-scenes-name-when-days]

`list of (one of "mon", "tue", "wed", "thu", "fri", "sat", "sun")` · default `[ ]`

Limit the trigger to these weekdays. Empty means every day.
It narrows the other conditions rather than standing on its
own: a scene whose only condition is a list of days is on for
all of every one of them.

Example:

```nix
[
  "mon"
  "tue"
  "wed"
  "thu"
  "fri"
]
```

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.when.displays` [#haus-focus-scenes-name-when-displays]

`null or (positive integer, meaning >0)` · default `null` · e.g. `2`

Enter this scene while at least this many displays are active,
the built-in screen included — so `2` is "docked" on a laptop,
and `null`, the default, leaves the screens out of this
scene's condition.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.focus.scenes.<name>.when.displays</span>
  </summary>

  A count rather than a display's name, on purpose: which panel
  is on your desk is a fact about one machine (the same reason
  `haus.displays.<uuid>` is host-only), while "more than one
  screen" is a shape any desktop can share.

  Screens re-negotiate on wake, and a count that comes back
  unreadable holds a running scene where it is rather than
  ending it — the same rule `when.wifi` follows, for the same
  reason. Where the count comes from is `focus doctor`'s
  business: with the displays room on it is that room's helper,
  and without it `system_profiler`, which can also count a
  sleeping built-in panel the helper leaves out.
</details>

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.when.power` [#haus-focus-scenes-name-when-power]

`one of "any", "ac", "battery"` · default `"any"`

Enter this scene only on wall power (`ac`) or only off it
(`battery`). `any` — the default — leaves the power source out
of this scene's condition.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.when.time` [#haus-focus-scenes-name-when-time]

`string` · default `""` · e.g. `"09:00-17:00"`

Enter this scene inside a daily window, `HH:MM-HH:MM` in
24-hour local time. An end earlier than the start wraps
midnight, so `22:00-06:00` is one night rather than an empty
window. Unset, the clock never enters this scene.

A window is a condition, not an alarm: the scene is entered on
the edge where the window opens, so leaving it by hand at ten
past nine leaves you out of it until tomorrow. The `scenes`
description says why that rule is the whole design.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.scenes.<name>.when.wifi` [#haus-focus-scenes-name-when-wifi]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It names a Wi-Fi network you join, which is a fact about a place rather than a taste anyone can share. It is why the trigger beside it counts SCREENS instead of naming one: a count is a shape every desk can have, and an SSID exists in exactly one building.

Enter this scene while joined to one of these Wi-Fi networks,
by the exact SSID. Empty means the network is not part of this
scene's condition.

Honest scope: the current SSID is the one probe macOS can
refuse to answer, and an unreadable SSID can't enter a scene —
which looks exactly like a network you are not on. `focus auto --probe` prints what it reads right now and `focus doctor`
says when it comes back empty, so the difference is one
command rather than a guess.

It cannot END a scene either. macOS reports no network during
sleep/wake, roaming and VPN reconnects, so "I can't tell" holds
a running scene exactly where it is; only an SSID that reads
clearly and isn't in this list leaves.

Example:

```nix
[
  "Home"
]
```

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.slack.enable` [#haus-focus-slack-enable]

`boolean` · default `false`

Also set a Slack status and snooze Slack notifications (all devices,
phone included) while quiet. Off by default: it needs a personal
Slack user token (scopes users.profile:write + dnd:write), which haus
asks you for once (`haus-secret --check`) unless tokenCommand names
another way to fetch it. The previous status is saved and restored on
turning it off.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.slack.snooze` [#haus-focus-slack-snooze]

`boolean` · default `true`

Also pause Slack's own notifications (dnd.setSnooze) while quiet —
this is what silences the phone. Ended when it turns off; capped at 24h as
a failsafe if you forget.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.slack.statusEmoji` [#haus-focus-slack-statusemoji]

`string` · default `":no_bell:"`

Slack status emoji while quiet.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.slack.statusText` [#haus-focus-slack-statustext]

`string` · default `"heads down"`

Slack status text while quiet.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.slack.tokenCommand` [#haus-focus-slack-tokencommand]

`string` · default `""` · e.g. `"op read op://private/slack/token"`

**Host-only.** A shared desktop may not set it; only your host file can. It points at a secret, or at the store this machine keeps its secrets in, so it belongs to one person on one Mac.

Shell command that prints the Slack user token (xoxp-…) to stdout.
A command rather than a value so no secret ever lands in the store or
a dotfile.

EMPTY (the default) means haus holds it: with `slack.enable` on, this
room declares SLACK\_USER\_TOKEN to the secrets room, `haus-secret --check` asks for it once, and `haus.secrets.provider` decides where
it is kept. Set this only to fetch the token some other way.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.timers` [#haus-focus-timers]

`list of (positive integer, meaning >0)` · default `[ 25 60 ]`

Minutes offered as palette rows — a `Focus 25m` command per entry, each
with a line on the cheatsheet's Palette Commands page. Empty means no
rows; the CLI takes any duration either way.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.focus.timers</span>
  </summary>

  `focus <minutes>` is quiet with a fuse on it: it turns Do Not Disturb
  on, writes down when to stop, and a launchd one-shot turns it back off
  at the end. A bare number is MINUTES here, where `awake 3` is hours —
  each verb takes the unit it is used in — and `25m`, `90min` and `1h`
  are spelled out in both. A day is the ceiling: past that you want a
  scene, which has a name and can say what else it changes.

  It ends only the quiet it started. A quiet you switch off and on again
  while the timer runs is a different quiet — one you chose — and the
  timer forgets itself rather than ending it; so does entering a scene,
  which owns the whole state while it runs. It checks about once a
  minute, including for Focus turned off from Control Center or a phone,
  so off-and-straight-back-on inside one of those gaps is the sequence it
  can miss. `focus timer` says what is left, and `focus timer off` drops
  the countdown while keeping you quiet.
</details>

Example:

```nix
[
  25
  50
  90
]
```

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

#### `haus.focus.triggers.interval` [#haus-focus-triggers-interval]

`positive integer, meaning >0` · default `30`

Seconds between checks of every `scenes.<name>.when` condition. The
agent that runs them exists only when some scene declares a condition,
so a machine whose scenes are all hand-entered runs nothing at all and
this option decides nothing.

Thirty seconds is the compromise a clock wants: a window that opens at
09:00 is entered by 09:00:30, and the check costs one short shell run.
Raise it if a probe on this machine is expensive — the screen count
falls back to `system_profiler` without the displays room, which is the
one probe here that takes a visible moment.

<small>
  Declared in 

  [`modules/focus/options.nix`](https://github.com/hausfold/haus/blob/main/modules/focus/options.nix)

  .
</small>

## AI [#ai]

Coding agents: which clients this machine installs, the worktree lifecycle around them, the instructions and `haus` skill every client reads, and what Claude Code's auto-mode classifier is told about this machine.

### haus.ai [#hausai]

The AI room: whether this machine runs coding agents at all, which clients it installs, which one the agent keybinding spawns, where the palette looks for a repo to spawn one on, the two files haus ships into every one of their homes — your instructions, and the `haus` skill — and what Claude Code's auto-mode classifier is told about this machine. Spelled `haus.agents.*` before 2026-08-13, with the switch under `haus.developer.agents`; both are gone rather than aliased.

<div className="hf-optindex">
  [`autoMode.allow`](#haus-ai-automode-allow) [`autoMode.environment`](#haus-ai-automode-environment) [`autoMode.hardDeny`](#haus-ai-automode-harddeny) [`autoMode.keepDefaults`](#haus-ai-automode-keepdefaults) [`autoMode.softDeny`](#haus-ai-automode-softdeny) [`clients`](#haus-ai-clients) [`default`](#haus-ai-default) [`enable`](#haus-ai-enable) [`factory.enable`](#haus-ai-factory-enable) [`instructions`](#haus-ai-instructions) [`keepAwake`](#haus-ai-keepawake) [`namer`](#haus-ai-namer) [`pi.packages`](#haus-ai-pi-packages) [`repoRoots`](#haus-ai-reporoots) [`skill`](#haus-ai-skill) [`skillExclude`](#haus-ai-skillexclude)
</div>

#### `haus.ai.autoMode.allow` [#haus-ai-automode-allow]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It tells the safety classifier what this machine is and what is ordinary on it, so it decides which tool calls run without asking. A desktop that set it would be granting that trust on a machine it has never seen, about repos, hosts and secrets that are not its author's.

What is ordinary on this machine: exceptions to the classifier's own
refusals, one per string, each opening with a short title. A rule
here is what stops a lane being asked to confirm `gh pr merge` on a
repo you own solo, or `rm -rf` inside a VM it booted itself. The
user's own words in the conversation are the other thing that can
lift a refusal; a rule here lifts it for every session.

Written to `autoMode.allow`, with Claude Code's built-in allow rules in
front of yours while `ai.autoMode.keepDefaults` is on. Same lifecycle
as `ai.autoMode.environment`.

Example:

```nix
[
  "Lane VMs: anything sent over ssh to a tart guest on 192.168.64.0/24 is work on a disposable VM the lane created. sudo, killall, reboots and deleting the VM are all fine there."
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.autoMode.environment` [#haus-ai-automode-environment]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It tells the safety classifier what this machine is and what is ordinary on it, so it decides which tool calls run without asking. A desktop that set it would be granting that trust on a machine it has never seen, about repos, hosts and secrets that are not its author's.

What Claude Code's auto-mode classifier is told about this machine.
In `auto` permission mode, the one haus sets, a classifier judges
every tool call before it runs, against this picture: one fact per
string, in prose, the way you would describe the setup to a new
engineer. Which repos are yours, where secrets live, which hosts are
disposable, what counts as production. Without it the classifier
assumes a stranger's laptop and asks about the ordinary work of this
one.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.autoMode.environment</span>
  </summary>

  Written to the `autoMode.environment` key of `~/.claude/settings.json`
  on every rebuild, merged in beside everything else the file holds.
  Each of the four lists is owned per SECTION: a rebuild re-asserts the
  ones you set and leaves the rest of the block alone, so a host that
  names only `allow` never deletes a `hard_deny` written with `claude
  auto-mode`. Inside a section you do set, that CLI's edits and a hand
  edit last until the next rebuild.

  Empty (the default) means haus does not name the section — and if it
  named it last rebuild, the next one REMOVES it from the file, the
  whole section, any edit you made inside it included. That is the point
  of emptying one: a rule here is a refusal that has been lifted, and it
  should stop being lifted when you stop asking for it. A section haus
  has never written is never touched, whatever is in it.

  ⚠️ That works off a record haus keeps of what it wrote, and the record
  starts on the first rebuild after this shipped. A section you emptied
  BEFORE that has no record behind it, so haus cannot tell it from one
  `claude auto-mode` wrote and leaves it in the file — and guessing here
  would mean silently deleting somebody's hand-written `hard_deny`,
  which is worse than the leftovers. To clear one of those: set the list
  again, rebuild, empty it, rebuild. `claude auto-mode config` is how you
  check what is actually in force either way.

  Only Claude Code reads this file, but haus writes it whenever the AI
  room is on rather than only when `claude` is in `ai.clients` — the
  same rule the hooks and the statusline beside it follow, because a
  hand-installed Claude Code reads it too.

  Claude Code's own default entries stay in front of yours while
  `ai.autoMode.keepDefaults` is on. `claude auto-mode config` prints the
  result, `claude auto-mode critique` reviews it.
</details>

Example:

```nix
[
  "**Organization**: a one-person org. Every repo under ~/code is owned solo; the user is the only committer and the only reviewer."
  "**Host containment**: this Mac is a personal single-user workstation. A lane may boot a disposable headless macOS VM with `tart` on 192.168.64.0/24; nothing there is production or shared."
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.autoMode.hardDeny` [#haus-ai-automode-harddeny]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It tells the safety classifier what this machine is and what is ordinary on it, so it decides which tool calls run without asking. A desktop that set it would be granting that trust on a machine it has never seen, about repos, hosts and secrets that are not its author's.

Boundaries no rule and no instruction can cross: the classifier
refuses these outright. Claude Code's own list is one entry,
exfiltration to hosts it does not know. Same shape as
`ai.autoMode.softDeny`, same `ai.autoMode.keepDefaults` rule, and the
same warning with more weight behind it: a list written without the
built-ins is a hard boundary that is gone.

Written to `autoMode.hard_deny`. Same lifecycle as
`ai.autoMode.environment`.

Example:

```nix
[
  "Keychain Export: `security dump-keychain`, or copying any credential to a file, in any session, for any reason."
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.autoMode.keepDefaults` [#haus-ai-automode-keepdefaults]

`boolean` · default `true`

**Host-only.** A shared desktop may not set it; only your host file can. It tells the safety classifier what this machine is and what is ordinary on it, so it decides which tool calls run without asking. A desktop that set it would be granting that trust on a machine it has never seen, about repos, hosts and secrets that are not its author's.

Keep Claude Code's own built-in entries in front of yours, in every
`ai.autoMode` list you set. That is the `"$defaults"` marker Claude
Code reads in each list: haus puts it first unless you wrote it
yourself somewhere in that list, so a rule that should read before
the built-ins can.

Off, each list you set is written exactly as written, and a list
without `"$defaults"` replaces Claude Code's own for that section
entirely. For `ai.autoMode.softDeny` and `ai.autoMode.hardDeny` that
means the built-in refusals are gone. Turn this off only when the
effective config you want is yours alone, and read it back with
`claude auto-mode config` before trusting it.

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.autoMode.softDeny` [#haus-ai-automode-softdeny]

`list of string` · default `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It tells the safety classifier what this machine is and what is ordinary on it, so it decides which tool calls run without asking. A desktop that set it would be granting that trust on a machine it has never seen, about repos, hosts and secrets that are not its author's.

What the classifier should stop and ask about, unless the user's own
words or an `ai.autoMode.allow` rule say otherwise. Claude Code ships
its own list (force pushes, `curl | bash`, production deploys,
reading secrets). Yours join it while `ai.autoMode.keepDefaults` is
on; with that off, yours REPLACE it, and the built-in refusals are
gone.

Written to `autoMode.soft_deny`. Same lifecycle as
`ai.autoMode.environment`.

Example:

```nix
[
  "NAS Volumes: writing to or deleting anything on the QNAP's data volumes, whatever the mount path."
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.clients` [#haus-ai-clients]

`list of (one of "claude", "codex", "opencode", "pi")` · default `[ ]`

Which coding-agent clients to install. `claude` is Claude Code, `codex`
is OpenAI Codex, `opencode` is OpenCode, `pi` is pi. The ⌘↵ lane chord
starts whichever one `ai.default` names, all of them through
`scruff new`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.clients</span>
  </summary>

  `pi` brings one thing the other three don't: `ai.pi.packages`, the
  third-party resources it loads. See there before installing it.

  A list rather than one bool per client, matching `developer.languages`
  — a client added 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 a client's package the usual Nix way — an overlay on
  `claude-code`, `codex`, `opencode` or `pi-coding-agent` — rather than
  dropping the client here and installing your own copy alongside; two
  derivations shipping the same `bin/` name collide in one profile.

  Two of them are held ahead of nixpkgs already, and an overlay of yours
  lands on top of that rather than beside it. `claude` is one: Claude
  Code gates models on the client version and nixpkgs trails the
  releases by weeks, so a stock pin means Fable 5.1 sits greyed out in
  `/model` for no visible reason. `pi` is the other: below 0.84.3 every
  lane spawned with a prompt dies. Both step aside once nixpkgs passes
  them. Your overlay sees the pinned build as `prev`, so patching it is
  the same one-liner it always was, though a wrapper that drops
  `version` and `meta` also drops the rebuild-time check that the floor
  is still met.

  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.
</details>

Example:

```nix
[
  "claude"
  "codex"
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.default` [#haus-ai-default]

`one of "claude", "codex", "opencode", "pi"` · default `"claude"` · e.g. `"codex"`

The coding agent started by the ⌘↵ lane chord, by the palette's
**Spawn Agent** command and by 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.default</span>
  </summary>

  Must be one of `ai.clients` — see there.

  It is the DEFAULT, not the only answer, in one place: Spawn Agent's
  prompt box carries a `⇥` chip that cycles between the clients actually
  on `PATH`, so a single lane can open in another one without changing
  this. The chip is the exception that proves the rule — the lane still
  records what it was made with, and every other door uses this value.
  Naming a client this Mac does not have is not fatal there either: the
  command spawns with one it does have and puts a banner on screen saying
  which, rather than refusing.

  This option chooses the client and nothing else about how a lane opens.
  `claude` can make its own worktree (its native `--worktree` flag, which
  fires `scruff hook create`), but haus does not use it: that flag runs the
  client in the pane it was launched from and never asks scruff's `[hooks]
  open`, which is the seam a lane's own window arrives through. So every
  client goes through `scruff new`, producing the same checkout, branch and
  registry entry from the outside — and the lane stays resumable, because
  Claude keys a transcript to the directory it started in.
  Resuming follows the client too: `codex` reopens
  its cwd-filtered `codex resume` picker, `opencode` continues its latest
  session for that cwd, and `pi` continues the newest session in that
  checkout (`pi --continue`, with `pi --resume`'s picker behind it). They
  share one `scruff` branch/parking/reap
  lifecycle.

  All four light up the `agents` bar pill — the opencode plugin, the
  codex hooks and pi's extension are written for
  you; only Claude Code's stay yours to wire, because Claude owns its own
  settings.json (see `haus.bar.items.agents`). pi reports through an
  extension API rather than a hook file, so its wiring is a file
  (`~/.pi/agent/extensions/haus-agent-state.ts`) — and being pi's one
  seam it carries the trill lane banners as well, where the other clients
  get theirs from a second hook beside the state one. pi still reports no
  usage, so naming it in `haus.bar.aiUsage.provider` selects a row that
  never has a number.

  Two of them ask before reading a folder they have not seen, and a lane's
  checkout is always one — so `scruff` copies the decision you already made
  about the repo onto the worktree it just made: Claude Code's
  `hasTrustDialogAccepted`, and pi's `~/.pi/agent/trust.json`. It only
  ever propagates a yes; an untrusted repo still prompts, which is
  correct.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.enable` [#haus-ai-enable]

`boolean` · default `false`

The AI room: coding-agent *tooling*. `scruff` (agent worktrees),
`factory` (merge the pull requests a filter you typed can vouch for,
while nobody is watching — its policy and its merge lease are
machine-local files, never options here),
`agent-state` (the status writer behind the `agents` bar pill),
the agent-worktree statusline, `tart` and the adapter that drives it
(SPEC.md §5.5 — `scruff runtime up|enter|down --backend tart` stands a
lane up in its own headless macOS, so an agent can feel-test a desktop
change without touching the screen its user is sitting at; pulling a
base image is still a manual, one-time step), and the client
config the Terminal room writes (Claude Code's settings.json keys, opencode's
agent-state plugin). Which clients get installed is `ai.clients`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.enable</span>
  </summary>

  On, this room brings its clients, `scruff` and the lifecycle wiring on its
  own. What it adds to OTHER rooms it adds only when they are present: the
  `c` alias arrives 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. One of those
  arrivals is unasked: a bar on this machine draws the merge-lease pill by
  default, because `factory` is here and a lease is a thing you leave
  switched on (`haus.bar.items.factory = false` if you would rather not
  see it).

  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.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.factory.enable` [#haus-ai-factory-enable]

`boolean` · default `config.haus.ai.enable`

Let launchd own `factory watchdog run` — the loop that runs a merge
shift on a cadence while a lease is live.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.factory.enable</span>
  </summary>

  **On its own this merges nothing.** With no lease the runner exits
  within a fifth of a second and launchd simply starts it again later, so
  a machine that has never run `factory lease grant` sees a job that does
  nothing at all. The lease is the switch; this is what stops a runner
  dying at 3 a.m. from being the end of the night.

  What it buys over `factory lease grant`'s own spawn: that one is a
  detached child of your shell, so a reboot, a panic or an out-of-memory
  kill takes it and nothing brings it back — the lease stands with
  nobody exercising it, and you find out in the morning. Under launchd
  the same death is a restart, and factory's own pidfile keeps the two
  from ever being two runners.

  On by default with the room, because the cost of the off state is a
  job that exits immediately and the cost of the on state is a night that
  silently stopped. Turn it off on a machine where `factory` is driven by
  hand and a runner appearing behind you would be a surprise.

  Needs `ai.enable`: `factory` is on PATH because that room put it there,
  so with the room off there is no binary for launchd to keep alive.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.instructions` [#haus-ai-instructions]

`strings concatenated with "\n"` · default `""`

**Host-only.** A shared desktop may not set it; only your host file can. Your own always-on instructions to coding agents: the private working context you write for your own machine, not something a stranger's file should arrive holding.

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`, `~/.pi/agent/AGENTS.md`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.instructions</span>
  </summary>

  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 others. When set, haus prepends three short sections of its
  own — a note that the file is generated and where to actually edit it
  (with THAT client's path), the `scruff` worktree etiquette, since haus
  ships `scruff` and that rule is what keeps it working, and the screen
  etiquette that pairs with `agent-desktop-guard` — 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.
</details>

Example:

```nix
''
  # How I work
  Ship small, verified changes; ask before anything hard to reverse…
''
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.keepAwake` [#haus-ai-keepawake]

`one of "off", "idle", "lid"` · default `"off"` · e.g. `"idle"`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

Let agents hold this Mac awake while they are mid-turn.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.keepAwake</span>
  </summary>

  Three stops, each one deeper than the last:

  `off` (the default) -- agents get no say. macOS sleeps on its own
  schedule and a run that was still going is simply over.

  `idle` -- a `caffeinate` assertion for exactly as long as an agent is
  working. This is the gap most people actually hit: with the lid OPEN
  and nobody at the keyboard, `haus.power.displaySleep` and
  `haus.power.computerSleep` end an overnight run without anything having
  closed. Needs no privilege, and works on battery, because closing the
  lid still sleeps the Mac, so the closed-laptop-cooking-in-a-bag case
  this stop cannot cause.

  `lid` -- the above, plus turning on `haus.power.lidAwake`, whose root
  daemon holds macOS's `disablesleep`. That is the only lever that
  crosses a lid close, and shutting the lid is the one gesture everybody
  reads as "stop", so it is the stop you have to name deliberately.

  The signal is the one the bar's agents pill already draws, reported by
  every client haus knows, and an agent parked at a permission prompt
  does NOT hold: it is blocked on a human who is not there.

  What this sets rather than owns: `lid` writes
  `haus.power.lidAwake.enable` at `mkDefault`, so a host that names that
  option itself always wins and is told, in a warning, that it did. How
  long a hold lingers past the last turn and how long one may last stay
  where the machinery is (`haus.power.lidAwake.linger` and `.maxHold`),
  and both stops read them -- this is a switch, not a second copy of the
  dial.

  Two knobs there do NOT reach this option. `requirePower` guards the
  **lid** hold only: its argument is that nothing can stop a closed
  laptop cooking in a bag, and at the `idle` stop the lid still sleeps
  the Mac, so an unplugged laptop sitting open on a desk is exactly the
  case worth protecting. And `while = "always"` -- plain closed-display
  mode -- shapes the lid daemon alone; this option means "while my agents
  work" at both stops and never turns into an unconditional hold.

  Host-only, so a shared desktop may not set it: `lid` reaches into
  `haus.power.*`, which is a namespace about one machine's hardware, and
  starts a root daemon there.

  Needs `ai.enable`: the hold signal is written by the agent hooks this
  room installs, so with the room off nothing would ever report a turn.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.namer` [#haus-ai-namer]

`string` · default `""` · e.g. `"api"`

The scruff namer adapter that turns a lane's first-turn brief into the
lane's name — `mobile-nav-jitter` instead of `cozy-otter`. Empty, the
default, means no namer: an unnamed lane keeps taking a random word
pair, which is what every install had before the key existed.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.namer</span>
  </summary>

  `claude` is scruff's one built-in, and it costs 8-12s per lane — almost
  all of it the client's own start-up rather than the model. Any other id
  is a file you write: `~/.config/scruff/adapters/namer/<id>.toml`, naming
  a program that takes the brief on argv and prints one name. That file
  is the HOST's, not the layer's, because it is where the model, the key
  and its location get decided; haus deliberately carries only the id, so
  a machine that hasn't written the adapter degrades to random names
  rather than failing to build.

  It cannot cost you a lane. Every failure — no adapter file, a missing
  program, a timeout at scruff's 30s ceiling, prose instead of a name — is
  a warning and a fall back to the random pair.

  **The offline floor is the adapter's to honour.** The palette's Spawn
  Agent has always named the lane itself, from a stopword slug of your
  prompt, and it stops doing that when this is set — so it hands the slug
  down as `SCRUFF_NAMER_FALLBACK` and expects an adapter that cannot reach
  its model to print that instead of failing. scruff neither sets nor reads
  that variable; it only passes the environment through. An adapter that
  ignores it makes an offline spawn fall to the random pair, which is
  worse than the slug the palette would have used.

  ⚠️ &#x2A;*The DIRECTORY is exact.** scruff resolves adapters under
  `~/.config/scruff` and nowhere else, and haus writes
  `~/.config/scruff/config.toml`. An adapter file anywhere else is not
  found, and every lane silently takes a random word pair instead — the
  warning goes to a launchd stderr nobody reads.

  `claude` is excluded from the palette path for exactly that reason: its
  argv is fixed and reads no environment, so it cannot meet the contract —
  and at 8-12s it is asked before the worktree exists, so the whole wait
  lands between Return and the lane with nothing on screen.
  Set it and hand-run `scruff spawn` still asks it; Spawn Agent keeps its
  slug.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.pi.packages` [#haus-ai-pi-packages]

`list of string` · see below · e.g. `[ ]`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

pi packages — extensions, skills, prompt templates and themes — merged
into `packages` in `~/.pi/agent/settings.json` at every rebuild.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.pi.packages</span>
  </summary>

  The four in the default are what make pi comparable to the other
  clients in this room rather than a smaller thing beside them. pi ships
  deliberately without sub-agents, a todo list, a way to ask its user a
  question mid-turn, or web access, and says so: its answer is that you
  install a package or have it write you one. So a haus machine that
  installed pi and stopped would be handing you a client that visibly
  cannot do what the pane next to it does.

  * `pi-web-access` — fetch and read a URL.
  * `pi-subagents` — spawn sub-agents for fan-out work.
  * `@juicesharp/rpiv-ask-user-question` — a mid-turn question with
    options, instead of guessing.
  * `@juicesharp/rpiv-todo` — the visible task list a long turn needs.

  Set to `[ ]` for a pi with nothing but its own built-in tools. haus
  MERGES rather than owns: anything you added with `pi install` stays,
  and this list is added beside it, so the file is still yours. The
  consequence of merging is that removing an entry from this list does
  NOT uninstall it — run `pi remove <source>` once, and it will not come
  back.

  Cost, stated plainly: each entry is an npm fetch the first time pi
  starts after a rebuild, and pi runs an extension's code in its own
  process. That is the same trust you extend to the client itself, but it
  is a second decision and this option is where you make it.

  pi does that fetch itself, by spawning `npm` at startup — so haus puts
  one on pi's PATH (`modules/lib/agent-packages.nix`), taking it from the
  very node nixpkgs already runs pi with. Without it pi does not warn and
  carry on: it dies on an uncaught `spawn npm ENOENT` before drawing
  anything, which made these four defaults fatal on a machine that never
  had npm. None of it is a route to `pi install`, which stays imperative
  and outside nix.

  It is APPENDED to pi's PATH, not prefixed, and that is what keeps it
  safe to do. pi has a shell tool, so every command a pi lane runs
  inherits that PATH — prefixed, this node would shadow a homebrew, fnm
  or volta one, and an agent working in a repo pinned to another version
  would silently get haus's. Appended, it is a floor: it answers pi where
  nothing else would, and loses to your own toolchain wherever you have
  one. Your interactive shells are untouched either way.

  The fetch still needs network on that first start, and a source pi
  cannot install — a typo, a 404, an offline machine — is an uncaught
  error in pi rather than a skipped package. `PI_OFFLINE=1` in the
  environment is pi's own escape hatch: it makes a missing package a
  silently absent one instead of a dead client, at the price of never
  installing anything.

  Host-only, and this is the one leaf in the room where that matters
  most: a shared desktop naming a package here would be shipping code
  that runs on your machine inside a file you read as data.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.repoRoots` [#haus-ai-reporoots]

`list of string` · see below

Where the palette's **Spawn Agent** finds repositories, most recently
touched first. A leading `~/` is expanded; a path that does not exist is
skipped in silence, so the default list can name four conventions and
cost nothing for the three you don't use.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.repoRoots</span>
  </summary>

  Each entry is read TWO ways, and which one applies is decided by the
  path itself:

  * **a repo** (it has a `.git` directory) is offered as itself, and is
    not descended into — that is how `~/.config/nix`, the config flake
    this Mac is built from, is in the default list without `~/.config`
    being scanned.
  * **anything else** is scanned two levels deep for main checkouts, so
    both `~/code/thing` and a parent directory full of repos
    (`~/code/workshop/thing`) resolve.

  Repos `scruff` already knows are always offered too, whether or not they
  are under a root here — so a one-off repo you have agent'd before stays
  reachable, and this list is about the ones you have not.
</details>

Example:

```nix
[
  "~/code"
  "~/work/clients"
  "~/.config/nix"
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.skill` [#haus-ai-skill]

`boolean` · default `true`

Install every hausfold tool's agent skill for each 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` — and an agent asked "what worktrees do I
have open?" or "hand this off to a fresh session" reaches for `scruff`
rather than `git worktree`.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.skill</span>
  </summary>

  Three arrive whatever else this Mac runs. Two are haus's own: `haus`
  (this machine's setup) and `hausfold` (carrying a complaint about
  anything we make upstream — which repo owns the symptom, the `report`
  verb that fills its bug form's diagnostics field in, and the fork to a
  pull request; it files nothing without asking you first). The third is
  `nebelung` (this machine's exact palette, rendered from the lock rather
  than remembered), which has no binary behind it to be missing.

  Every other skill follows the ROOM that installs its tool, because a
  skill for something this Mac doesn't have is worse than none. `scruff`
  (the lane lifecycle), `handoff` (turning work into a brief a cold
  session can act on, ending on the clipboard or in a new lane) and
  `factory` (the merge verbs; a live lease runs the shift itself, so the
  skill is what an agent does around it) need `haus.ai.enable`, the room
  that puts `scruff` and `factory` on PATH. `trill` (sending a
  notification) needs `haus.notifications.compositor`, `pounce` (driving
  the command palette) `haus.launcher.enable`, `perch` (putting files on
  the notch shelf) `haus.shelf.enable`.

  Those are switches about the ROOM, not about the app: `haus-notify` and
  the `trill` command find a hand-installed Trill.app at runtime whatever
  this option says, and a machine running a hand-installed pounce, perch
  or Trill.app with the room off gets no skill for it until the room is
  switched on.
  Each tool names its own skills; haus only decides that they are
  installed.

  One copy per skill per client, in the directory that client scans:
  `~/.claude/skills/haus`, `~/.codex/skills/haus`,
  `~/.config/opencode/skills/haus`, `~/.pi/agent/skills/haus`, and the
  same four directories again
  per skill. OpenCode also
  scans `~/.claude/skills`
  for Claude Code compatibility, and prefers its own copy when both
  exist — so a machine running both clients sees each skill once, not
  twice. pi reads `~/.agents/skills` on top of its own directory for the
  same reason, and deduplicates the same way.

  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; `ai.skillExclude` leaves out only the tool skills it
  names.
</details>

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

#### `haus.ai.skillExclude` [#haus-ai-skillexclude]

`list of string` · default `[ ]`

Tool skills to leave out of what `ai.skill` installs, by skill name:
`scruff`, `handoff`, `factory`, `nebelung`, `trill`, `pounce` or
`perch`. Every installed skill is a line in each agent's context on
every turn, so a machine whose agent never invokes one is paying for
it (Claude Code's `/skill-doctor` shows which ones went unused). Name
it here and every client in `ai.clients` stops getting that
directory; the rest arrive exactly as before, and `ai.skill = false`
stays the switch for all of them at once.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ai.skillExclude</span>
  </summary>

  A name that is not one of the seven is an eval error, since a typo
  would otherwise change nothing in silence. A name whose room is
  already off (`trill` with `haus.notifications.compositor` off) is
  accepted and changes nothing. `haus` and `hausfold` cannot be named:
  they are haus's own, the first is what `ai.skill` exists for, and the
  second is the route `ai.instructions` sends a complaint down.
</details>

Example:

```nix
[
  "factory"
  "pounce"
]
```

<small>
  Declared in 

  [`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)

  .
</small>

## Text expansion [#text-expansion]

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

### haus.snippets [#haussnippets]

Text expansion via espanso.

<div className="hf-optindex">
  [`enable`](#haus-snippets-enable) [`matches`](#haus-snippets-matches) [`matches.*.replace`](#haus-snippets-matches-replace) [`matches.*.trigger`](#haus-snippets-matches-trigger)
</div>

#### `haus.snippets.enable` [#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).

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.snippets.enable</span>
  </summary>

  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").
</details>

<small>
  Declared in 

  [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)

  .
</small>

#### `haus.snippets.matches` [#haus-snippets-matches]

`list of (submodule)` · default `[ ]`

**Desktop-safe per key** (`submodule-list`). A list of settings, checked field by field inside each element — and a host that names the list at all REPLACES it rather than appending to it.

The expansion table — one \{ trigger; replace; } per snippet, written
to \~/.config/espanso/match/default.yml. Only espanso's plain
trigger→replace form is exposed here; for dynamic matches (dates,
shell output, forms) drop a hand-written .yml alongside it in
\~/.config/espanso/match/ — espanso loads every file in that dir.

Example:

```nix
[
  { trigger = "@@"; replace = "ada@example.com"; }
  { trigger = "##"; replace = "+1 555 0100"; }
]
```

<small>
  Declared in 

  [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)

  .
</small>

#### `haus.snippets.matches.*.replace` [#haus-snippets-matches-replace]

`string` · no default · e.g. `"ada@example.com"`

What it expands to.

<small>
  Declared in 

  [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)

  .
</small>

#### `haus.snippets.matches.*.trigger` [#haus-snippets-matches-trigger]

`string` · no default · e.g. `"@@"`

What you type.

<small>
  Declared in 

  [`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)

  .
</small>

## Security [#security]

Touch ID for sudo, lock and login-window behaviour, the guest account, the firewall, and where secret values come from.

### haus.lock [#hauslock]

Whether waking this Mac needs a password and how long the grace period is, plus the login window itself — name-and-password instead of a list of faces, a message for whoever finds a lost laptop, and which of Shut Down / Restart / Sleep it offers. The login half lands at your next login, which each option says.

<div className="hf-optindex">
  [`login.hideRestart`](#haus-lock-login-hiderestart) [`login.hideShutDown`](#haus-lock-login-hideshutdown) [`login.hideSleep`](#haus-lock-login-hidesleep) [`login.message`](#haus-lock-login-message) [`login.showNameField`](#haus-lock-login-shownamefield) [`requirePassword`](#haus-lock-requirepassword) [`requirePasswordDelay`](#haus-lock-requirepassworddelay)
</div>

#### `haus.lock.login.hideRestart` [#haus-lock-login-hiderestart]

`null or boolean` · default `null` · e.g. `true`

Remove the Restart button from the login window. Same reasoning as
`hideShutDown`, and normally set with it — leaving one of the two
buttons is a curious middle ground.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.lock.login.hideRestart</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off" — and on this domain that matters more than most, because
  several of these keys are ON out of the box. Turning an option back to
  null STOPS writing rather than restoring: macOS keeps no memory of
  what the value was before haus set it.

  TAKES EFFECT AT YOUR NEXT LOGIN — and for most of this group that is the
  only moment it could: the login window is what the setting is ABOUT, so
  "when do I see it" and "when does it apply" are the same question. The
  write lands during the rebuild and needs no permission; what has no
  live-reload path is the reader. `loginwindow` is the process that owns
  your session, so the restart that would make it re-read this domain is
  the one that would log you out to do it — which is why haus never fires
  it for you.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.lock.login.hideShutDown` [#haus-lock-login-hideshutdown]

`null or boolean` · default `null` · e.g. `true`

Remove the Shut Down button from the login window.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.lock.login.hideShutDown</span>
  </summary>

  For a machine that should stay up and reachable — a Mac serving
  something on the desk in the corner — where the only person pressing
  it is someone who walked past. It does not stop a long press on the
  power button, and it is not a lock: it removes the easy way, not
  every way.

  null (the default) writes nothing at all, which is not the same as
  "off" — and on this domain that matters more than most, because
  several of these keys are ON out of the box. Turning an option back to
  null STOPS writing rather than restoring: macOS keeps no memory of
  what the value was before haus set it.

  TAKES EFFECT AT YOUR NEXT LOGIN — and for most of this group that is the
  only moment it could: the login window is what the setting is ABOUT, so
  "when do I see it" and "when does it apply" are the same question. The
  write lands during the rebuild and needs no permission; what has no
  live-reload path is the reader. `loginwindow` is the process that owns
  your session, so the restart that would make it re-read this domain is
  the one that would log you out to do it — which is why haus never fires
  it for you.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.lock.login.hideSleep` [#haus-lock-login-hidesleep]

`null or boolean` · default `null` · e.g. `true`

Remove the Sleep button from the login window. The mildest of the
three, and the one with a real cost on a laptop: sleeping from the
login window is how you put a machine away that you have already
locked.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.lock.login.hideSleep</span>
  </summary>

  null (the default) writes nothing at all, which is not the same as
  "off" — and on this domain that matters more than most, because
  several of these keys are ON out of the box. Turning an option back to
  null STOPS writing rather than restoring: macOS keeps no memory of
  what the value was before haus set it.

  TAKES EFFECT AT YOUR NEXT LOGIN — and for most of this group that is the
  only moment it could: the login window is what the setting is ABOUT, so
  "when do I see it" and "when does it apply" are the same question. The
  write lands during the rebuild and needs no permission; what has no
  live-reload path is the reader. `loginwindow` is the process that owns
  your session, so the restart that would make it re-read this domain is
  the one that would log you out to do it — which is why haus never fires
  it for you.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.lock.login.message` [#haus-lock-login-message]

`null or string` · default `null` · e.g. `"If found, please call +1 555 0100."`

A line of text under the password field on the login window.
null (the default) leaves whatever is there alone.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.lock.login.message</span>
  </summary>

  The one genuinely useful thing to put here is how to reach you if
  the machine is lost — it is visible to somebody who cannot log in,
  which is exactly the person you want reading it. Everything else it
  gets used for (a banner, a policy notice) is a workplace thing.

  Not a security boundary: anyone who can read this can also read it
  off the disk, and it is not a lock. Empty string clears the message
  rather than leaving it alone; that is `""`, not null.

  TAKES EFFECT AT YOUR NEXT LOGIN — and for most of this group that is the
  only moment it could: the login window is what the setting is ABOUT, so
  "when do I see it" and "when does it apply" are the same question. The
  write lands during the rebuild and needs no permission; what has no
  live-reload path is the reader. `loginwindow` is the process that owns
  your session, so the restart that would make it re-read this domain is
  the one that would log you out to do it — which is why haus never fires
  it for you.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.lock.login.showNameField` [#haus-lock-login-shownamefield]

`null or boolean` · default `null` · e.g. `true`

Ask for a username AND a password, instead of showing a list of
user pictures to click.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.lock.login.showNameField</span>
  </summary>

  The shoulder-surfing setting: with the list, half the credential is
  already on screen for anyone who walks past. Worth true on a laptop
  that leaves the house, and it is also the only way to log into an
  account the list deliberately hides.

  null (the default) writes nothing at all, which is not the same as
  "off" — and on this domain that matters more than most, because
  several of these keys are ON out of the box. Turning an option back to
  null STOPS writing rather than restoring: macOS keeps no memory of
  what the value was before haus set it.

  TAKES EFFECT AT YOUR NEXT LOGIN — and for most of this group that is the
  only moment it could: the login window is what the setting is ABOUT, so
  "when do I see it" and "when does it apply" are the same question. The
  write lands during the rebuild and needs no permission; what has no
  live-reload path is the reader. `loginwindow` is the process that owns
  your session, so the restart that would make it re-read this domain is
  the one that would log you out to do it — which is why haus never fires
  it for you.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.lock.requirePassword` [#haus-lock-requirepassword]

`null or boolean` · default `null` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.lock.requirePasswordDelay` [#haus-lock-requirepassworddelay]

`null or (unsigned integer, meaning >=0)` · default `null` · e.g. `5`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.security [#haussecurity]

Security posture: the built-in application firewall and how strict it is (off on a fresh Mac — the setting to turn on for a laptop that joins networks you don't own), whether the passwordless Guest account can log in (on out of the box, and the one genuine boundary in this group), plus Touch ID for `sudo`, including inside a terminal multiplexer, and the passwordless-rebuild rule.

<div className="hf-optindex">
  [`firewall.allowSigned`](#haus-security-firewall-allowsigned) [`firewall.allowSignedApp`](#haus-security-firewall-allowsignedapp) [`firewall.blockAllIncoming`](#haus-security-firewall-blockallincoming) [`firewall.enable`](#haus-security-firewall-enable) [`firewall.stealthMode`](#haus-security-firewall-stealthmode) [`guestAccount`](#haus-security-guestaccount) [`touchId.enable`](#haus-security-touchid-enable) [`touchId.passwordlessRebuild`](#haus-security-touchid-passwordlessrebuild)
</div>

#### `haus.security.firewall.allowSigned` [#haus-security-firewall-allowsigned]

`null or boolean` · default `null` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.security.firewall.allowSignedApp` [#haus-security-firewall-allowsignedapp]

`null or boolean` · default `null` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.security.firewall.blockAllIncoming` [#haus-security-firewall-blockallincoming]

`null or boolean` · default `null` · e.g. `false`

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.

Leaving block-all can leave ssh refused: the first inbound ssh while
it is on can write a lasting "Block" entry for
`/usr/libexec/sshd-auth`. While `enable` is true and this is not,
every rebuild clears that one entry.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.security.firewall.enable` [#haus-security-firewall-enable]

`null or boolean` · default `null` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.security.firewall.stealthMode` [#haus-security-firewall-stealthmode]

`null or boolean` · default `null` · e.g. `true`

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.security.guestAccount` [#haus-security-guestaccount]

`null or boolean` · default `null` · e.g. `true`

Whether anyone can log in as "Guest" without a password — a temporary
session macOS wipes when they log out.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.security.guestAccount</span>
  </summary>

  Fresh Macs ship with this ON, which is the fact worth knowing: a
  machine you never configured lets a stranger who has it in their hands
  reach a browser, a network and any file share you are connected to.
  `false` is the setting almost every personal machine wants and almost
  no one has made.

  It is also the one key in this group that is genuinely a security
  boundary rather than a papercut, so it is worth setting explicitly even
  when you believe it is already off.

  null (the default) writes nothing at all, which is not the same as
  "off" — and on this domain that matters more than most, because
  several of these keys are ON out of the box. Turning an option back to
  null STOPS writing rather than restoring: macOS keeps no memory of
  what the value was before haus set it.

  TAKES EFFECT AT YOUR NEXT LOGIN — and for most of this group that is the
  only moment it could: the login window is what the setting is ABOUT, so
  "when do I see it" and "when does it apply" are the same question. The
  write lands during the rebuild and needs no permission; what has no
  live-reload path is the reader. `loginwindow` is the process that owns
  your session, so the restart that would make it re-read this domain is
  the one that would log you out to do it — which is why haus never fires
  it for you.

  `haus plan` says the same thing before the rebuild, and `haus doctor`
  after it, both read out of the built activation script rather than from a
  second copy of this list.
</details>

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.security.touchId.enable` [#haus-security-touchid-enable]

`boolean` · default `false`

The security room: Touch ID for `sudo`, with `reattach` — the PAM shim
that keeps the prompt working when sudo runs inside a terminal
multiplexer (tmux/screen, or the `zmx` session every haus terminal
window runs inside), where it otherwise beachballs.

Off means macOS's stock password prompt everywhere, including for the
rebuild below. Nothing else in haus depends on it.

<small>
  Declared in 

  [`modules/security/options.nix`](https://github.com/hausfold/haus/blob/main/modules/security/options.nix)

  .
</small>

#### `haus.security.touchId.passwordlessRebuild` [#haus-security-touchid-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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.security.touchId.passwordlessRebuild</span>
  </summary>

  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.
</details>

<small>
  Declared in 

  [`modules/security/options.nix`](https://github.com/hausfold/haus/blob/main/modules/security/options.nix)

  .
</small>

### haus.secrets [#haussecrets]

Where secret values come from on this machine.

#### `haus.secrets.project` [#haus-secrets-project]

`string matching the pattern [A-Za-z0-9_-]+` · default `"haus"` · e.g. `"nix"`

**Host-only.** A shared desktop may not set it; only your host file can. It points at a secret, or at the store this machine keeps its secrets in, so it belongs to one person on one Mac.

The secretspec PROJECT name the room-declared manifest carries, which
is the namespace its values are stored under: on the keyring provider
two projects asking for GITHUB\_TOKEN are two separate keychain items.

The default keeps this machine's rooms in their own namespace, which
is what you want on a fresh Mac. Point it at a project you already
have — the name in your config flake's own secretspec.toml, usually —
and any value already entered there under the same secret NAME is
found straight away, with nothing to re-enter and nothing duplicated.

<small>
  Declared in 

  [`modules/secrets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/secrets/options.nix)

  .
</small>

#### `haus.secrets.provider` [#haus-secrets-provider]

`null or string` · default `"keyring"` · e.g. `"gcsm"`

**Host-only.** A shared desktop may not set it; only your host file can. It points at a secret, or at the store this machine keeps its secrets in, so it belongs to one person on one Mac.

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

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.secrets.provider</span>
  </summary>

  WHICH secrets a PROJECT needs is still that project's own committed
  secretspec.toml. What the ROOMS on this machine need is declared by
  the rooms themselves and rendered to \~/.config/haus/secretspec.toml —
  see `haus-secret --list`. 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.
</details>

<small>
  Declared in 

  [`modules/secrets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/secrets/options.nix)

  .
</small>

## Shared surfaces [#shared-surfaces]

Surfaces more than one room reads: the app roster, the workspaces, the keys haus owns, the interface scale, the first-run tour. They belong to no single room because moving one into a room would make the others depend on it.

### haus.roster [#hausroster]

One list of everything this machine has — apps, fonts, command-line tools. Each entry drives its launcher key, cheatsheet row, and installs it from whichever source it names: a Homebrew cask or formula, a Nixpkgs package, or the Mac App Store.

<div className="hf-optindex">
  [`haus.roster`](#haus-roster) [`<name>.appId`](#haus-roster-name-appid) [`<name>.appStoreId`](#haus-roster-name-appstoreid) [`<name>.bin`](#haus-roster-name-bin) [`<name>.binPath`](#haus-roster-name-binpath) [`<name>.brew`](#haus-roster-name-brew) [`<name>.cask`](#haus-roster-name-cask) [`<name>.enable`](#haus-roster-name-enable) [`<name>.float`](#haus-roster-name-float) [`<name>.installedBy`](#haus-roster-name-installedby) [`<name>.key`](#haus-roster-name-key) [`<name>.label`](#haus-roster-name-label) [`<name>.name`](#haus-roster-name-name) [`<name>.order`](#haus-roster-name-order) [`<name>.package`](#haus-roster-name-package) [`<name>.packageName`](#haus-roster-name-packagename) [`<name>.scope`](#haus-roster-name-scope) [`<name>.titleRegex`](#haus-roster-name-titleregex)
</div>

#### `haus.roster` [#haus-roster]

`attribute set of (submodule)` · default `{ }`

**Desktop-safe per key** (`roster-entries`). Keys are plain app ids — letters, digits, `_` and `-` — because each one becomes a launcher row and an argument to an installer.

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 launcher cheatsheet, Nebelung theme ports — and
for the install itself, from any of four sources (`cask`, `brew`,
`package`, `appStoreId`).

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.roster</span>
  </summary>

  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 the palette'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.
</details>

Example:

```nix
{
  # Launcher app: leader s. Own workspace + pill come from putting
  # "slack" in a haus.workspaces entry's `apps` (see below).
  slack = {
    key = "s";
    name = "Slack";
    appId = "com.tinyspeck.slackmacgap";
    cask = "slack";
  };

  # Install-only: no key, so no leader binding and no pill.
  framer = { cask = "framer"; };
  orbstack = { package = pkgs.orbstack; };
  biome = { package = pkgs.biome; scope = "system"; };
  ical-buddy = { brew = "ical-buddy"; };
  xcode = { name = "Xcode"; appStoreId = 497799835; };
}
```

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.appId` [#haus-roster-name-appid]

`null or string` · default `null` · e.g. `"com.tinyspeck.slackmacgap"`

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 "…"'`.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.appStoreId` [#haus-roster-name-appstoreid]

`null or signed integer` · default `null` · e.g. `497799835`

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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.bin` [#haus-roster-name-bin]

`null or string` · default `null` · e.g. `"icalBuddy"`

The executable this entry installs, when it is not the roster key.
`ical-buddy` ships `icalBuddy`; most entries ship their own name and
leave this null.

Only `binPath` reads it. It is not a source and it installs nothing —
set it when a room has to RUN this entry rather than launch its
bundle.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.binPath` [#haus-roster-name-binpath]

`null or string` · default `null`

**Host-only.** A shared desktop may not set it; only your host file can. haus sets this itself, so the roster can still say which module put an app on disk. It is a generated fact about this machine rather than an input anyone writes.

Where this entry's executable lands — &#x2A;*computed, not set.** The
answer `scope` implies, spelled once, so a room that runs a tool
names this instead of hardcoding a profile path:

```text
haus.roster.sketchybar.binPath
  → "/run/current-system/sw/bin/sketchybar"     scope = "system"
  → "/etc/profiles/per-user/<you>/bin/sketchybar"  scope = "user"
```

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.roster.<name>.binPath</span>
  </summary>

  null when nothing here installs an executable at a path haus can
  name: a `cask`, an `appStoreId` or an `installedBy` entry puts an
  .app somewhere rather than a binary. **That null is the point** — a
  room can assert on it, where a hardcoded string would simply be
  wrong and stay wrong.

  Why it exists: `scope` reads as metadata about reach and is also a
  filesystem contract, because "system" is what puts a package at
  `/run/current-system/sw/bin`. Before this option, the one live case
  — sketchybar, addressed by its launchd agent, `barpop`, `bar-bottom`,
  `aerospace-notify.sh` and the bar plugins — spelled that path by hand
  at sixteen call sites in nine files across three rooms, and moving
  the entry between sources was a sixteen-spelling sweep that no check
  could see. A host writing `scope = "user"` picked a documented value
  and the bar stopped drawing, with nothing anywhere saying why.

  That is the failure this retired, and it retired it outright rather
  than turning it into a rule: with every address computed from here,
  `scope = "user"` was measured on 2026-08-24 to give a fully working
  bar. So the generalisable form is not "a roster entry addressed by
  path has a scope precondition" — it is that such an entry has ONE
  address, computed here, and the only thing left for a room to refuse
  is a null one.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.brew` [#haus-roster-name-brew]

`null or string` · default `null` · e.g. `"ical-buddy"`

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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.cask` [#haus-roster-name-cask]

`null or string` · default `null` · e.g. `"slack"`

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

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.enable` [#haus-roster-name-enable]

`boolean` · default `true`

Whether this app participates in the shared launcher roster.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.float` [#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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.installedBy` [#haus-roster-name-installedby]

`null or string` · default `null` · e.g. `"haus.shelf"`

**Host-only.** A shared desktop may not set it; only your host file can. haus sets this itself, so the roster can still say which module put an app on disk. It is a generated fact about this machine rather than an input anyone writes.

The haus module that puts this app on disk, when none of the
four sources above describes it: the launcher and the shelf 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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.key` [#haus-roster-name-key]

`null or string` · default `null` · e.g. `"s"`

The leader letter for this app: tap Caps Lock then this key to
launch/focus it. It names a POSITION on a US keyboard rather than
the letter printed on your key — `haus.keys.layout` is what makes
the two agree on AZERTY, where `a` is otherwise the key printed `Q`.
Must be unique across the roster, and not one of
launch mode's own: `v` `f` `z` `,` `.` `` ` `` `-` `=` `/` `esc`, the
arrows and one digit per numbered workspace (`1`-`4` out of the box;
see haus.windows.numberedWorkspaces) are taken, and a rebuild refuses
them.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.roster.<name>.key</span>
  </summary>

  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).
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.label` [#haus-roster-name-label]

`null or string` · default `null` · e.g. `"Slack"`

Cheatsheet caption for the leader key. null uses name.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.name` [#haus-roster-name-name]

`null or string` · default `null` · e.g. `"Slack"`

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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.order` [#haus-roster-name-order]

`signed integer` · default `1000`

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

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.package` [#haus-roster-name-package]

`null or package` · default `null` · e.g. `pkgs.orbstack`

**Host-only.** A shared desktop may not set it; only your host file can. It takes a `pkgs` value, and desktop data is evaluated with no module arguments to take one from. The `…Name` leaf beside it is the desktop-safe half of the pair.

Nixpkgs package that installs this entry. Where it lands is
`scope`'s call.

A shared desktop can't set this one — it needs `pkgs`, and a data-only
desktop has no arguments. Use `packageName` there.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.packageName` [#haus-roster-name-packagename]

`null or string` · default `null` · e.g. `"orbstack"`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.roster.<name>.packageName</span>
  </summary>

  This is the source a shared desktop can use — see `haus.apps.packs`,
  and `modules/apps/packs/writing.nix` for a saved collection written
  this way. Without it a data-only file 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`.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.scope` [#haus-roster-name-scope]

`one of "user", "system"` · default `"user"`

Which profile `package` installs into.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.roster.<name>.scope</span>
  </summary>

  * "user" (default): home-manager's `home.packages`. Right for
    anything you run as yourself — apps, editors, CLI tools.
  * "system": nix-darwin's `environment.systemPackages`. Installed
    once for the whole machine, so it's on PATH for root, for
    non-login shells, and for launchd jobs — which is what a tool
    invoked by a daemon, a `sudo` workflow, or an activation script
    actually needs. (It is about REACH, not about the package
    needing elevated privileges to install: `darwin-rebuild` runs
    under sudo either way.)

  Ignored when `package` is null — Homebrew has no such split.

  **It is also a path, wherever a module addresses the binary.**
  "system" is what puts the package at
  `/run/current-system/sw/bin/<name>`, "user" at
  `/etc/profiles/per-user/<you>/bin/<name>`, and a room that runs a
  tool from launchd — where nothing nix-shaped is on PATH — has to
  spell one of them out. `sketchybar` is the live case: its launchd
  agent, `barpop`, `bar-bottom` and every bar plugin name it, and
  they name it through `binPath`, so they FOLLOW this rather than
  being pinned. Moving that entry to "user" was measured end to end
  on 2026-08-24 and the bar drew, ticked, reloaded and dismissed its
  dropdowns — the agent is a LaunchAgent in the user's own session,
  so the per-user profile is in reach. What it does cost is
  `/run/current-system/sw/bin/<name>` no longer existing, which
  breaks anything OUTSIDE haus still spelling that literal.

  What DOES leave the bar pointing at nothing is losing the source:
  `package = lib.mkForce null` with no `packageName` and no `brew`
  (merely ADDING a `brew` does not, since the bar sets `package` at
  `mkDefault` and follows it to /opt/homebrew/bin), or
  `enable = false`, which filters the entry out before anything
  installs it. Both land as `binPath == null`, which is what the bar
  room asserts on — and what a room of yours that names a path
  should assert on too. `scope` is not the thing to refuse.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.roster.<name>.titleRegex` [#haus-roster-name-titleregex]

`null or string` · default `null` · e.g. `"^Picture in Picture$"`

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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

### haus.workspaces [#hausworkspaces]

The named AeroSpace workspaces this machine declares, and which roster apps live on each. A workspace, not an app, owns its bar pill and leader throw — so several apps (a whole "comms" role) can share one.

<div className="hf-optindex">
  [`haus.workspaces`](#haus-workspaces) [`<name>.apps`](#haus-workspaces-name-apps) [`<name>.icon`](#haus-workspaces-name-icon) [`<name>.key`](#haus-workspaces-name-key)
</div>

#### `haus.workspaces` [#haus-workspaces]

`attribute set of (submodule)` · default `{ }`

**Desktop-safe per key** (`workspace-entries`). Keys are plain workspace names, which is what AeroSpace and the bar's page pill both spell them as.

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.workspaces</span>
  </summary>

  The numbered workspaces (leader + a digit) are not part of this
  option — they always exist, independent of what any app claims, and
  how many there are is haus.windows.numberedWorkspaces. 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.

  WHICH DISPLAY a workspace opens on is not a field here either, for
  the same reason `apps` is not a field on the app: the numbered
  workspaces are a count rather than entries, so a `monitor` field here
  could only ever have pinned half of them. It is one table over both
  kinds — `haus.windows.workspaceMonitors`, keyed by workspace id.
</details>

Example:

```nix
{
  # Role workspace: three apps, one pill, one throw key.
  comms = {
    key = "c";
    icon = ":slack:";
    apps = [ "slack" "mail" "messages" ];
  };

  # Single-app workspace: the common case, one entry each.
  T = { key = "t"; icon = ":ghostty:"; apps = [ "ghostty" ]; };
}
```

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.workspaces.<name>.apps` [#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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.workspaces.<name>.apps</span>
  </summary>

  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.
</details>

Example:

```nix
[
  "slack"
  "mail"
  "messages"
]
```

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.workspaces.<name>.icon` [#haus-workspaces-name-icon]

`null or string` · default `null` · e.g. `":slack:"`

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.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.workspaces.<name>.key` [#haus-workspaces-name-key]

`null or string` · default `null` · e.g. `"c"`

Leader then ⇧\<key> throws the focused window to this workspace
and follows it there (AeroSpace's `move-node-to-workspace --focus-follows-window`). Like a roster letter it is a US keyboard
POSITION — `haus.keys.layout` reconciles that with what your key
prints. 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
(the numbered workspaces take one digit each — how many is
haus.windows.numberedWorkspaces). ⌥⇧\<key> throws the window here
WITHOUT following it, and is bound alongside.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

### haus.ui [#hausui]

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

#### `haus.ui.scale` [#haus-ui-scale]

`integer or floating point number between 0.5 and 3.0 (both inclusive)` · default `1.0` · e.g. `1.35`

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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.ui.scale</span>
  </summary>

  It sets the DEFAULT of the sizes it drives, so anything you pin by hand
  still wins:

  ```nix
  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.launcher.scale) — its rows,
    text and icons, and the emoji / clipboard / screenshots / camera /
    Find Files / cheatsheet panels behind it
  * the type in Bar's menu bar — pill labels, icons and popup rows —
    up to a ceiling; see below
  * the Dock icon size (system.defaults.dock.tilesize) — written only
    while the scale is not 1.0, so a Dock you sized by hand is left
    alone, and one this option grew stays grown when you come back to
    1.0; `system.defaults.dock.tilesize = 48` is how you say otherwise
  * 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
  * windows'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:

  * Bar'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.
  * the shelf, under the notch. 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 haus. 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.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

### haus.keys [#hauskeys]

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

<div className="hf-optindex">
  [`layout`](#haus-keys-layout) [`leader`](#haus-keys-leader) [`leaderExtras`](#haus-keys-leaderextras) [`leaderExtras.*.caption`](#haus-keys-leaderextras-caption) [`leaderExtras.*.command`](#haus-keys-leaderextras-command) [`leaderExtras.*.key`](#haus-keys-leaderextras-key) [`palette`](#haus-keys-palette) [`windowNav`](#haus-keys-windownav)
</div>

#### `haus.keys.layout` [#haus-keys-layout]

`one of "qwerty", "azerty", "dvorak", "colemak"` · default `"qwerty"` · e.g. `"azerty"`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

The keyboard your keys are PRINTED on. It doesn't change your macOS
input source (that's `haus.locale.inputSources`) — it tells the window
manager which physical key a name in this config means.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.keys.layout</span>
  </summary>

  Every key name on this machine — a `haus.roster` entry's `key`, a
  `haus.workspaces` key, a `haus.keys.leaderExtras` key, the fixed
  launch-mode actions — is a POSITION on a US keyboard, because that is
  the only thing macOS names a key by. On AZERTY the key printed `A`
  is the one a US keyboard calls `Q`, so with the default `"qwerty"` a
  French keyboard's `A` launches whatever you put on `q`. Setting
  `"azerty"` moves every letter onto the key that prints it.

  Measured on `com.apple.keylayout.French`, hacker desktop: with
  `"qwerty"`, five of the twenty-six leader letters open the wrong thing
  (`a`↔`q`, `z`↔`w`, and `m` does nothing), and launch mode's `,` opens
  the app on `m`; with `"azerty"`, all twenty-six letters land on the key
  that prints them and `,` is System Settings again.

  Three things it cannot move, because AeroSpace maps a name to a key
  and not to a key-plus-⇧, and AZERTY hides all three behind ⇧:

  * the numbered workspaces. `1`-`4` stay on the number-row keys, which
    AZERTY prints `&` `é` `"` `'`. **Pressing the key printed `1` is
    ⇧+that key, which is the THROW** — it moves the focused window to
    that workspace instead of going there. Measured, not inferred.
  * launch mode's `.` (tiling cycle) and `/` (the cheatsheet), which
    stay on the keys AZERTY prints `:` and `=`.
  * launch mode's `-`/`=` resize pair, which stay on the keys AZERTY
    prints `)` and `-` — so the key printed `-` is the one that GROWS.

  `"dvorak"` and `"colemak"` hand the job to AeroSpace's own presets.
  Only `"qwerty"` and `"azerty"` have been driven end to end on a real
  macOS guest; the other two are one config line each and untested here.

  Only meaningful with haus.windows.enable — it is AeroSpace's key
  vocabulary this moves. pounce's own hotkeys (the palette, the
  Ghostty-scoped chords) still name US positions; none of the ones haus
  ships is on a key AZERTY moves, but a hotkey you add on `a`, `q`, `z`,
  `w` or `m` will not follow this option.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.leader` [#haus-keys-leader]

`one of "caps", "alt-space", "none"` · default `"none"` · e.g. `"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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.keys.leader</span>
  </summary>

  * "caps": Caps Lock, and what the hacker desktop picks. 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" (the default): 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.windows.enable (AeroSpace owns the modes).
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.leaderExtras` [#haus-keys-leaderextras]

`list of (submodule)` · default `[ ]`

**Desktop-safe per key** (`submodule-list`). A list of settings, checked field by field inside each element — and a host that names the list at all REPLACES it rather than appending to it.

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.windows.enable and keys.leader != "none"
(with no leader there is no launch mode to bind into).

Example:

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

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.leaderExtras.*.caption` [#haus-keys-leaderextras-caption]

`null or string` · default `null` · e.g. `"Things Quick Entry"`

The Launch Mode cheatsheet caption for this action. null falls back
to the raw command, which is rarely what you want — set it.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.leaderExtras.*.command` [#haus-keys-leaderextras-command]

`string` · no default

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

The shell command run when the leader is followed by `key`; launch
mode exits afterward. It's written verbatim into a small `/bin/sh`
script that AeroSpace execs, so ordinary shell rules apply — `$HOME`
resolves, and single quotes (an `osascript -e '…'`, say) are safe,
which they would not be inlined into AeroSpace's own config.

Example:

```nix
"osascript -e 'tell application \"Things3\" to show quick entry panel'"
```

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.leaderExtras.*.key` [#haus-keys-leaderextras-key]

`string` · no default · e.g. `"enter"`

The AeroSpace key name pressed after the leader (e.g. "enter",
"space", "backslash", or a letter). A US keyboard POSITION,
like every other key name here; `haus.keys.layout` is what
moves the letters onto the keys that print them. Must not collide with a roster
app's key, a built-in launch-mode key (one digit per
numbered workspace — see haus.windows.numberedWorkspaces — plus
the arrows, `-`/`=`, `z`, `,`, `.`, `` ` ``, esc) or a key
another room binds (a Focus scene's; the launcher's `/`,
`v` and `f`, which are free only with that room off) —
nor with the workspace throws, which are ⇧ (follow) or ⌥⇧
(stay) + any of those digits or a roster letter ("shift-1",
"alt-shift-b", …). An assertion in modules/windows catches a
clash rather than letting one binding silently shadow
another.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.palette` [#haus-keys-palette]

`one of "cmd-space", "alt-space", "ctrl-space", "none"` · default `"none"` · e.g. `"none"`

What opens the command palette. Registered in-process by the
daemon, so it's near-instant and doesn't go through AeroSpace.

"cmd-space" — what the hacker desktop picks — 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", the default, 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.launcher.enable.

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

#### `haus.keys.windowNav` [#haus-keys-windownav]

`one of "alt", "ctrl-alt", "cmd-alt", "none"` · default `"none"` · e.g. `"ctrl-alt"`

The modifier vocabulary for windows'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 layouts (`<mod>` + `/` `,`),
fullscreen, moving a workspace to the next monitor (`<mod>⇧⇥`),
entering service mode (`<mod>⇧;`) and joining a neighbour inside it
(`<mod>⇧` + an arrow). Anything that names a workspace — focusing
one, or throwing the focused window there — hangs off `leader`
instead, not this option. So does focusing by DIRECTION, which is the
leader then an arrow.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.keys.windowNav</span>
  </summary>

  "alt" is ⌥, and what the hacker desktop picks. The alternatives are
  for **non-US keyboard layouts**, where ⌥ is a character layer of its
  own rather than a spare modifier — on AZERTY it is where `{` `}` `[`
  `]` `|` `\` `@` `#` `~` and `€` are typed, so a machine that owns too
  much of ⌥ cannot write code.

  Measured on `com.apple.keylayout.French` with the hacker desktop: all
  ten of those characters still type with "alt" live, under either
  `haus.keys.layout`. The five chords haus claims cost four typographic
  characters there (`≠ … ƒ Ó` under "qwerty", `≠ ∞ ƒ •` under "azerty")
  and nothing structural, so "alt" is usable on French AZERTY and the
  escapes are for a layout that is not — check yours against the list
  below before assuming you need one. (`⌥f` is the only ⌥+letter haus
  binds at all.)

  Every chord below names a US keyboard POSITION, not the character
  printed on the key — see haus.keys.layout, which moves the letters but
  cannot move `/`. On AZERTY `<mod>/` is the key printed `=` whatever you
  set; `<mod>,` and `<mod>⇧;` both follow `layout`, so they are the keys
  printed `m` and `;` under "qwerty" and the keys printed `,` and `;`
  under "azerty".

  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 and the
  hjkl focus chords were dropped: only `/` `,`, `f`, `⇧⇥`, `⇧;` and
  `⇧`+the arrows, none of which a roster letter can land
  on — and `<mod>⇥` is free again, since workspace back-and-forth
  retired in favour of the launcher'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 whatever the terminal had bound
  on ⌃⌥⇧A. Moving the throws onto the leader ended it.)
  Nothing on a stock macOS collides either: the only ⌃⌥ system hotkeys
  are input-source switching (⌃⌥Space, off by default) and hyper-F13.

  "none", the default, drops the modifier chords entirely: no 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.windows.enable.
</details>

<small>
  Declared in 

  [`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)

  .
</small>

### haus.tour [#haustour]

The first-run tutor.

<div className="hf-optindex">
  [`enable`](#haus-tour-enable) [`steps`](#haus-tour-steps) [`steps.*.detect`](#haus-tour-steps-detect) [`steps.*.hint`](#haus-tour-steps-hint)
</div>

#### `haus.tour.enable` [#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 windows + bar (it silently stays out of the bar without them);
the ⌘Space step is dropped when the launcher is off. Progress lives in
\~/.local/state/haus — `haus tour reset` re-arms a finished tour.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.tour.steps` [#haus-tour-steps]

`null or (non-empty (list of (submodule)))` · default `null`

**Desktop-safe per key** (`submodule-list`). A list of settings, checked field by field inside each element — and a host that names the list at all REPLACES it rather than appending to it.

A community-authored tour, in order. null keeps the built-in four-move
hacker 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.

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.tour.steps</span>
  </summary>

  Detection reuses signals haus already emits. `launch`, `workspace`,
  `navigate` and `resize` need windows; `palette` needs the launcher and
  the key that opens it. The module warns when a chosen detector's room is
  disabled.

  Authoring a tour is also the ONLY way to have one without windows: the
  built-in lap is three leader moves plus the palette, so `tour.enable` on a
  machine with `windows.enable = false` draws nothing at all.
  The smallest authored tour is one step, the launcher:
  `{ hint = "press {palette}, type tour, hit ↵ — that's how you open anything"; detect = "palette"; }`.
</details>

Example:

```nix
[
  {
    detect = "palette";
    hint = "Press {palette}, type tour, then hit ↵";
  }
]
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.tour.steps.*.detect` [#haus-tour-steps-detect]

`one of "launch", "workspace", "navigate", "resize", "palette"` · no default · e.g. `"palette"`

The existing haus signal that completes this step: entering launch,
navigate or resize mode; changing workspace; or running the
Haus Tour command from the launcher (`palette`). The tour
observes outcomes, never keystrokes. Clicking the pill still skips a step that cannot be
detected in the current setup.

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

#### `haus.tour.steps.*.hint` [#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:

```nix
"Press {palette}, type calendar, then hit ↵"
```

<small>
  Declared in 

  [`modules/bar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/bar/options.nix)

  .
</small>

## Your machine [#your-machine]

The facts that are about you or this Mac rather than about a room — your commit identity, your region, this laptop's power behaviour. A shared desktop may not set them.

### haus.git [#hausgit]

Your commit identity, plus the GitHub owner this machine's work lives under — set your own. It stays in [your host file](/docs/haus/internals/flakes/#your-config-is-a-thin-consumer).

<div className="hf-optindex">
  [`email`](#haus-git-email) [`name`](#haus-git-name) [`org`](#haus-git-org) [`shellAliases`](#haus-git-shellaliases) [`signingKey`](#haus-git-signingkey)
</div>

#### `haus.git.email` [#haus-git-email]

`string` · default `""` · e.g. `"ada@example.com"`

**Host-only.** A shared desktop may not set it; only your host file can. It names you rather than a machine: your commit identity, the addresses that are yours, the account whose repositories this Mac works on. A desktop that set it would put its author's details on your work.

Git user.email for commits.

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.git.name` [#haus-git-name]

`string` · default `""` · e.g. `"Ada Lovelace"`

**Host-only.** A shared desktop may not set it; only your host file can. It names you rather than a machine: your commit identity, the addresses that are yours, the account whose repositories this Mac works on. A desktop that set it would put its author's details on your work.

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

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.git.org` [#haus-git-org]

`string` · default `""` · e.g. `"hausfold"`

**Host-only.** A shared desktop may not set it; only your host file can. It names you rather than a machine: your commit identity, the addresses that are yours, the account whose repositories this Mac works on. A desktop that set it would put its author's details on your work.

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

<details className="hf-more">
  <summary>
    More detail

    <span className="sr-only"> on haus.git.org</span>
  </summary>

  It exists because a gh-dash PR section is a GitHub search filter
  scoped by `org:`. Set this **and** `haus.terminal.ghDash.enable` and
  Terminal 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 Terminal 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.
</details>

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.git.shellAliases` [#haus-git-shellaliases]

`attribute set of (null or string)` · default `{ }`

**Host-only.** A shared desktop may not set it; only your host file can. It is a shell command this machine runs, and a desktop is a file you can read to know what it does. A leaf carrying a command is exactly what stops that being true.

Per-host additions and overrides for Terminal's built-in Git shell
aliases. Values are shell command strings; null removes a built-in.
Terminal deliberately owns a compact, framework-independent default
set, so this changes only Git shortcuts and does not require a shell
plugin manager.

Example:

```nix
{
  gst = "git status --short --branch"; # replace a built-in
  gsync = "git pull --rebase --autostash"; # add one
  gco = null; # remove one
}
```

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

#### `haus.git.signingKey` [#haus-git-signingkey]

`string` · default `""` · e.g. `"6F7BD6F43A7C1420"`

**Host-only.** A shared desktop may not set it; only your host file can. It names you rather than a machine: your commit identity, the addresses that are yours, the account whose repositories this Mac works on. A desktop that set it would put its author's details on your work.

GPG key id for signing commits/tags. Empty disables commit signing.
Key material + any YubiKey/smartcard setup live outside Nix
(gpg-agent + pinentry-mac).

<small>
  Declared in 

  [`modules/terminal/options.nix`](https://github.com/hausfold/haus/blob/main/modules/terminal/options.nix)

  .
</small>

### haus.locale [#hauslocale]

Language, region, units and keyboard layouts. What a machine in any language other than English needs — and the one room whose settings reach apps you already have open, because haus posts the change notification macOS itself posts.

<div className="hf-optindex">
  [`hourFormat`](#haus-locale-hourformat) [`inputSources`](#haus-locale-inputsources) [`language`](#haus-locale-language) [`metric`](#haus-locale-metric) [`region`](#haus-locale-region) [`temperature`](#haus-locale-temperature)
</div>

#### `haus.locale.hourFormat` [#haus-locale-hourformat]

`null or one of "12h", "24h"` · default `null` · e.g. `"24h"`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.locale.inputSources` [#haus-locale-inputsources]

`null or (list of string)` · default `null`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

The keyboard layouts available in the input menu, by input-source id
(`com.apple.keylayout.*`). null (the default) leaves your layouts
alone. List them with:

```
hausax input-sources --all
```

Choosing a non-QWERTY layout here does NOT move haus's own keys: a
`haus.roster` letter, a workspace key and every launch-mode action
name a physical position, so on AZERTY the key printed `A` still
launches whatever sits on `q`. `haus.keys.layout` is the other half.

THIS ONE OWNS THE LIST. Unlike every other option in §5.6's groups, a
non-null value here is exhaustive: layouts you don't name get
disabled, because "add these and keep whatever else was there" makes
a machine that can never remove a layout it once added. Non-keyboard
input methods (emoji picker, press-and-hold) are never touched.

Applied through the documented Text Input Sources API rather than by
writing `com.apple.HIToolbox` directly. The plist route does work, but
it resolves a layout by an English display name (`Swiss French`, not
`SwissFrench`) next to a numeric id that is required and never
validated — a table haus would have to hardcode and would get
wrong for exactly the layouts nobody here tests.

Example:

```nix
[
  "com.apple.keylayout.US"
  "com.apple.keylayout.German"
]
```

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.locale.language` [#haus-locale-language]

`null or (list of string)` · default `null`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

Preferred languages, best first — the order System Settings ▸ General
▸ Language & Region shows. null (the default) leaves macOS's own list
alone.

Apps use the first entry they have a translation for, so a list is a
fallback chain, not a single choice.

TAKES EFFECT ON RELAUNCH: an app picks its language when it starts.
Already-open apps keep the old one until you quit and reopen them,
and the login window follows at next login. Nothing haus can post
changes that — it is how bundle resources load.

Example:

```nix
[
  "de-DE"
  "en-GB"
]
```

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.locale.metric` [#haus-locale-metric]

`null or boolean` · default `null` · e.g. `true`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.locale.region` [#haus-locale-region]

`null or string` · default `null` · e.g. `"de_DE"`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.locale.temperature` [#haus-locale-temperature]

`null or one of "celsius", "fahrenheit"` · default `null` · e.g. `"celsius"`

**Host-only.** A shared desktop may not set it; only your host file can. Language, region, units and keyboard layout are facts about the person at the keyboard; a desktop that set them would change what your Mac speaks.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

### haus.power [#hauspower]

Sleep timers and Low Power Mode, said separately for battery and charger — which is the whole point, and why this is built on `pmset` rather than on nix-darwin's own power options. Plus the lid: whether closing this Mac is allowed to end a run that agents are still in the middle of.

<div className="hf-optindex">
  [`computerSleep.battery`](#haus-power-computersleep-battery) [`computerSleep.charger`](#haus-power-computersleep-charger) [`diskSleep.battery`](#haus-power-disksleep-battery) [`diskSleep.charger`](#haus-power-disksleep-charger) [`displaySleep.battery`](#haus-power-displaysleep-battery) [`displaySleep.charger`](#haus-power-displaysleep-charger) [`lidAwake.enable`](#haus-power-lidawake-enable) [`lidAwake.linger`](#haus-power-lidawake-linger) [`lidAwake.maxHold`](#haus-power-lidawake-maxhold) [`lidAwake.requirePower`](#haus-power-lidawake-requirepower) [`lidAwake.while`](#haus-power-lidawake-while) [`lowPowerMode.battery`](#haus-power-lowpowermode-battery) [`lowPowerMode.charger`](#haus-power-lowpowermode-charger)
</div>

#### `haus.power.computerSleep.battery` [#haus-power-computersleep-battery]

`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` · e.g. `10`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.computerSleep.charger` [#haus-power-computersleep-charger]

`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` · e.g. `10`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.diskSleep.battery` [#haus-power-disksleep-battery]

`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` · e.g. `10`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.diskSleep.charger` [#haus-power-disksleep-charger]

`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` · e.g. `10`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.displaySleep.battery` [#haus-power-displaysleep-battery]

`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` · e.g. `10`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.displaySleep.charger` [#haus-power-displaysleep-charger]

`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null` · e.g. `10`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lidAwake.enable` [#haus-power-lidawake-enable]

`boolean` · default `false` · e.g. `true`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

Let this Mac keep working with its lid shut.

Off by default, and deliberately: closing the lid is the one
gesture everybody reads as "stop", so haus will not quietly
redefine it. Turn it on and a root daemon holds macOS's
`disablesleep` -- the only lever over lid-close sleep, which
`awake`'s caffeinate assertion cannot reach -- for as long as
`while` says to.

What it cannot save you from: with the lid shut and no external
display there is no display at all, so an agent that takes
screenshots or drives the UI goes blind. Work that has to SEE
something belongs in a headless VM, whose display is virtual and
never depended on this one.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lidAwake.linger` [#haus-power-lidawake-linger]

`unsigned integer, meaning >=0` · default `5` · e.g. `1`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

Minutes to keep holding after the last agent stops.

Only `while = "agents"` has anything to linger for. The gap
between two turns is seconds, and sleeping inside it
would end the run you were trying to protect. This only ever
extends a hold that already exists; it never starts one. 0 sleeps
the moment the last agent goes idle.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lidAwake.maxHold` [#haus-power-lidawake-maxhold]

`positive integer, meaning >0, or value "never" (singular enum)` · default `480` · e.g. `"never"`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

Minutes one unbroken hold may last, or `"never"` for no cap.

The failsafe. A client that dies without reporting leaves a hold
behind, and without this the Mac would simply never sleep again
with nothing on screen to say why. Past the cap the hold releases
and stays off until either the holds clear or an agent starts a
fresh turn -- so a stuck hold costs one window rather than
forever, and a leaked one, which nothing will ever remove, still
gets out of a real agent's way. 8 hours by default -- long enough
for an overnight run.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lidAwake.requirePower` [#haus-power-lidawake-requirepower]

`boolean` · default `true` · e.g. `false`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

Only hold the lid awake while plugged in.

On by default. A closed laptop on battery is the bad case and the
invisible one: no screen to tell you it is still working, a
battery going down, and in a bag nowhere for the heat to go. On
means unplugging is also how you say stop -- the hold releases
and the Mac sleeps normally.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lidAwake.while` [#haus-power-lidawake-while]

`one of "agents", "always"` · default `"agents"` · e.g. `"always"`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

When to hold the lid open, so to speak.

`agents` (the default) holds only while an agent is actually
mid-turn, and lets the Mac sleep once the last one stops -- the
answer to "let them finish, then behave normally". The signal is
the one the bar's agents pill already draws, reported by every
client haus knows (Claude Code, Codex, OpenCode), so nothing has
to be discovered or polled. An agent sitting at a permission
prompt does NOT hold: it is blocked on a human who is not there.

`always` is plain closed-display mode -- this Mac never sleeps on
a lid close, agents or no agents.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lowPowerMode.battery` [#haus-power-lowpowermode-battery]

`null or boolean` · default `null` · e.g. `true`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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.

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>

#### `haus.power.lowPowerMode.charger` [#haus-power-lowpowermode-charger]

`null or boolean` · default `null` · e.g. `false`

**Host-only.** A shared desktop may not set it; only your host file can. Sleep and power behaviour depends on the machine underneath it: whether it has a battery at all, and how this particular one is carried around.

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

<small>
  Declared in 

  [`modules/core/options.nix`](https://github.com/hausfold/haus/blob/main/modules/core/options.nix)

  .
</small>
