# Documentation (/docs)
A Mac you can describe in one file and rebuild from it on any machine you own,
and the small native tools that live inside it. Two kinds of thing are
documented here: **the layer**, and **the apps**.
**haus** is the machinery; the rest are things that run on it, and run
perfectly well on a Mac that has never heard of it. **pounce** and **perch**
install from Homebrew with no Nix anywhere, and haus can install either for you
instead. **trill** installs from nowhere yet: it is in the incubator, and its
page says so before it says anything else.
The switcher at the top of the sidebar moves between the trees, and its glyph
tells you which one you're reading at a glance: the layer,
the launcher, the shelf,
the compositor.
Looking for a **desktop** (hacker and the rest)? A desktop is a set of values
for haus's own options rather than a project of its own, so it is documented
with the layer: [choosing one](/docs/haus/desktops/choosing) compares the four,
and each has a page of its own: [hacker](/docs/haus/desktops/hacker),
[everyday](/docs/haus/desktops/everyday),
[minimal](/docs/haus/desktops/minimal).
Reading this with an AI tool? These docs publish themselves in machine-readable
form: [`/llms.txt`](/llms.txt) is a table of contents with one-line
descriptions, for something that fetches selectively, and
[`/llms-full.txt`](/llms-full.txt) is every page inlined, for something that
would rather read the lot. On a machine that already has haus, prefer the `haus`
skill; it is generated from the revision you actually run.
[Changing your Mac with an agent](/docs/haus/rooms/agent-rebuilds) covers it.
# What haus is (/docs/haus)
**haus** is a layer for macOS. You describe the machine you want in one text
file: apps, window management, the bar, the shell, the colours, the macOS
settings you always change by hand. Then one command makes the Mac match it.
```nix
haus.theme.accent = "sapphire";
haus.hearth.editorName = "neovim";
haus.roster.slack = { key = "s"; name = "Slack"; cask = "slack"; };
```
```sh
haus rebuild
```
Slack is installed and bound to a launcher key, your terminal is themed, and
the change is a line in git you can read six months later.
That is the whole idea. The rest of these pages are the detail.
## Why bother [#why-bother]
* **It survives the machine.** A new Mac, or a wiped one, is one command away
from being the old one, because the old one was text.
* **It undoes.** A failed build never activates. A regretted change is
`haus rollback`, atomic, in seconds.
* **It's one setting, not forty.** The accent colour lands in the terminal,
the file manager, git, the bar and the browser at once, because they all
read the same value.
* **Nothing is buried.** Every knob is a documented option with a default, and
`haus edit` opens the file that sets them.
You do **not** need to know [Nix](https://nixos.org) going in. It is what makes
this reproducible, and the installer sets it up for you. You will pick some of
it up over time, and that's the whole curve.
## What's in the box [#whats-in-the-box]
haus is organised into twelve **rooms**, each one a capability you can turn on,
off, or leave alone. Turn on the ones you want; the rest cost you nothing.
| Room | What it does |
| ----------------------------------------------------- | ------------------------------------------------------------------------- |
| **[Apps](/docs/haus/rooms/apps)** | One list holds every app, font and CLI tool, and where each installs from |
| **[Appearance](/docs/haus/rooms/appearance)** | The palette, the accent, the wallpaper, fonts and interface scale |
| **[Displays](/docs/haus/rooms/displays)** | Per-screen scaling, said as an intent rather than a pixel count |
| **[Development](/docs/haus/rooms/development)** | Ghostty, zellij, zsh, the editor, Git tooling and language runtimes |
| **[Windows](/docs/haus/rooms/windows)** | Tiling, workspaces, and a launcher on a tap of Caps-Lock |
| **[Bar](/docs/haus/rooms/bar)** | A status bar: workspaces, weather, media, battery, clock |
| **[Launcher](/docs/haus/rooms/launcher)** | A ⌘Space command palette where every command is a file |
| **[Shelf](/docs/haus/rooms/shelf)** | A file shelf that drops out of the notch to catch drag-drops |
| **[Focus](/docs/haus/rooms/focus)** | One quiet switch: Do Not Disturb, your status, your hooks |
| **[AI](/docs/haus/rooms/ai)** | Coding agents, each with its own checkout of the repo |
| **[Text expansion](/docs/haus/rooms/text-expansion)** | Type a short trigger, get the long thing, in any app |
| **[Security](/docs/haus/rooms/security)** | Touch ID for `sudo`, lock behaviour, firewall, secrets |
Underneath them is the foundation nobody switches off: the macOS defaults, the
Homebrew policy and the `haus` CLI itself. The [full
reference](/docs/haus/reference/options) lists every option in every room, with
its type and default.
Which rooms are on is your **desktop's** decision, and disagreeing with it is one
line in your host file. Each piece also stands alone: you can take the whole
thing, or just [pounce](/docs/pounce), or just the palette.
## The shape of a haus machine [#the-shape-of-a-haus-machine]
Your config lives at `~/.config/nix` and it is **yours**: your name, your keys,
your app list. It imports haus; it never edits it. haus stays upstream, pinned
to an exact commit, and nothing about your machine changes until you run
`haus update`.
That split is the whole design. Your identity is never in someone else's repo,
and their improvements are never a surprise on your machine.
## Where to go next [#where-to-go-next]
# Start blank (/docs/haus/desktops/blank)
**Blank is a desktop that selects nothing.** It is the from-scratch choice, and
it exists so that "I'll build my own" stays inside the one-desktop model instead
of being a second mode with its own rules.
```nix
darwinConfigurations.lovelace = haus.mkHaus {
username = "ada";
hostname = "lovelace";
host = ./hosts/lovelace;
desktop = haus.desktops.blank;
};
```
The whole file, upstream, is this:
```nix
{
haus = { };
}
```
Everything else is the room catalogue's own neutral defaults doing the work.
## What you get [#what-you-get]
The foundation, and only that:
* **the `haus` CLI**: `rebuild`, `plan`, `diff`, `set`, `rollback`, `update`;
* **the app roster**: the one list that drives what's installed and from where,
ready for entries you add;
* **the Nix and rebuild plumbing**: pinned inputs, generations, garbage
collection;
* **haus's safe macOS defaults**: the conservative ones the foundation writes
on every machine, and nearly all of them soft, so your host outranks them
with a plain assignment. ([Two menu-bar keys are
not](/docs/haus/desktops/customizing#beating-a-default-haus-set), and say so.)
## What you don't [#what-you-dont]
No optional room is on: no bar, no tiling, no launcher, no shelf, no Focus
switch, no coding agents, no developer toolbelt, no Touch ID for `sudo`. No
global hotkey is claimed: Caps Lock stays Caps Lock and ⌘Space stays
Spotlight's. The wallpaper is left alone, the tour doesn't run, and no theme
files are written into apps you already had.
That last one is the point of Blank rather than a stripped hacker: nothing
here reaches outside haus and changes something you didn't ask for.
## Adding a room [#adding-a-room]
Every room is one switch in your host file at
`~/.config/nix/hosts//default.nix`:
```nix
haus.sill.enable = true; # the bar
haus.pounce.enable = true; # the launcher
haus.keys.palette = "cmd-space";
```
```sh
haus rebuild
```
A room enabled is a room that works: each one ships a neutral, useful
configuration, so you get a drawn bar and a working launcher without tuning
anything. Tune it afterwards from that room's page, or leave it.
Turning on the Bar doesn't bring the launcher, and turning on Development
doesn't bring coding agents. Where two rooms cooperate (agent pills on the bar,
agent commands in the launcher), the extra appears only when *both* are on, and
its absence never disables the room that offered it.
## Growing out of blank [#growing-out-of-blank]
If you find yourself turning on most of the catalogue, you are describing a
desktop that already exists. Try
[selecting one](/docs/haus/desktops/choosing) and overriding the two or three
things you disagree with. If what you have is genuinely yours, and you want it
on a second Mac or in someone else's hands, that's a
[desktop of your own](/docs/haus/desktops/creating).
# Choose a desktop (/docs/haus/desktops/choosing)
**haus supplies rooms. A desktop curates them. Your host file makes one yours.**
A **room** is one capability (the bar, the launcher, window management) with
its own options and its own switch. A **desktop** is a complete answer to *what
should this Mac feel like?*: which rooms are on, and what the visible choices
are. Your **host file** is where your name, your keys and your own overrides
live.
```text
haus foundation the rooms, their options, safe defaults
↓
one desktop hacker, everyday, minimal, blank, or yours
↓
your host file identity, secrets, hardware, and any override
↓
`haus set` what the palette and your agent write for you
```
Later layers win, deliberately. Anything a desktop chooses, you change with a
plain assignment in your host file: no `lib.mkForce` anywhere.
## Exactly one [#exactly-one]
A Mac runs **one** desktop. They don't stack, and the build says so: selecting
two is refused whether they disagree or not, and so is selecting the same one
twice, with a message of its own. Choosing again means changing one line, not
unpicking a pile.
If nothing you can name fits, that's what [Start blank](/docs/haus/desktops/blank) is
for: a desktop that selects nothing, so "build my own" is still one desktop
rather than a second mode.
## The four that ship [#the-four-that-ship]
| Desktop | For | Rooms it turns on |
| -------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| **[hacker](/docs/haus/desktops/hacker)** | the full thing: keyboard-first, developer, tiled | Development, AI, Windows, Bar, Launcher, Shelf, Focus, Security, plus the theme and generated wallpaper |
| **[everyday](/docs/haus/desktops/everyday)** | a Mac for someone who doesn't write code | Launcher, Bar, Shelf, Focus, Security, the theme and the generated wallpaper; no tiling, no leader key, no coding tools |
| **[minimal](/docs/haus/desktops/minimal)** | just the themed shell, on otherwise stock macOS | Development, Security and the palette. No bar, no tiling, no launcher, no wallpaper |
| **[blank](/docs/haus/desktops/blank)** | build your own from the foundation | none |
`everyday` deliberately leaves **Windows** off: tiling is good, but remapping
Caps Lock on the Mac of someone who didn't ask for it is not. `minimal` is still
a *developer* machine: few rooms, not few tools. A Mac with no developer
tooling is `everyday`.
## Selecting one [#selecting-one]
Your `~/.config/nix/flake.nix` names it in one line:
```nix
darwinConfigurations.lovelace = haus.mkHaus {
username = "ada";
hostname = "lovelace";
host = ./hosts/lovelace;
desktop = haus.desktops.everyday;
};
```
Then apply it:
```sh
haus rebuild
```
Leave the `desktop` line out and you get **hacker**, which is what every
machine installed before desktops existed is still running. The installer writes
the line for you; `HAUS_DESKTOP=minimal` picks a different one on an
[unattended install](/docs/haus/install#unattended-installs).
Vendor the `.nix` file into your config and point `desktop` at the path:
`desktop = ./desktops/writer.nix;`. There is no separate import mechanism for
someone else's desktop; that's the point of the format being closed.
## Changing your mind [#changing-your-mind]
Switch the line, rebuild, and if you don't like it, `haus rollback`. Nothing
about a desktop is one-way: it sets options, and options are what a rebuild
computes from scratch every time.
What a switch does **not** touch is anything outside haus: files you made, apps
you installed by hand, and the macOS settings haus never claimed. Turning a room
off removes what that room installed; it doesn't tidy up after you.
## Where to go next [#where-to-go-next]
# Create a desktop (/docs/haus/desktops/creating)
A **desktop** is a single `.nix` file whose only top-level key is `haus`. That is
the entire format, and the smallness is the point: a desktop can't run a script,
reach a macOS setting haus doesn't already expose, or hand the build a package of
its own. A stranger can read one in a minute and know the worst it can do, which
is what makes running one from someone you've never met reasonable.
haus ships four: [hacker, everyday, minimal and
blank](/docs/haus/desktops/choosing#the-four-that-ship). This page is how you
write yours.
## The format [#the-format]
```nix
{
haus = {
developer.enable = false;
prowl.enable = false;
keys.leader = "none";
theme = {
flavor = "latte";
accent = "rosewater";
};
sill.items.weather = false;
};
}
```
There is no `{ pkgs, lib, config, ... }:` header; this is data, not a module
function. No `system.defaults`, no `pkgs`, no activation hooks, no `imports`.
Everything stays inside the documented
[`haus.*` option surface](/docs/haus/reference/options), and only the leaves
marked safe for desktop data.
Someone selects it the same way they select one of haus's own:
```nix
desktop = ./desktops/writer.nix;
```
## A desktop is the whole answer [#a-desktop-is-the-whole-answer]
A host runs **exactly one**, so yours is not a diff against hacker; it is
the complete selection. Whatever you don't say, the rooms' own neutral defaults
decide, which for every optional room means **off**.
That is the one thing to check twice when you port something that used to be a
layer stacked on another desktop: the values it silently inherited are now
values it has to state. haus's own `everyday` is the worked example: it names
`collar.passwordlessRebuild` and `hush.enable` explicitly, because inheriting
them quietly was how the old preset worked and losing them quietly is how the
port would have failed.
Say what your desktop is **for** in a comment at the top, and say what it
deliberately leaves out. The reason a room is off is the part a reader can't
reconstruct.
## Teach it your own first lap [#teach-it-your-own-first-lap]
The built-in tour teaches haus's core moves in the bar, one hint at a time. A
desktop can replace that lap with its own and stay data-only. A desktop
that turns rooms off usually must, because the built-in one is leader moves plus
the launcher and has nothing left to teach without them:
```nix
haus.tour.steps = [
{
hint = "Press {palette}, type tour, then hit ↵";
detect = "palette";
}
{
hint = "Move to another workspace";
detect = "workspace";
}
];
```
Steps run in order and advance on signals haus already emits, never on
keystrokes. `detect` takes one of `launch`, `workspace`, `navigate`, `resize` or
`palette`; the first four need prowl, and `palette` needs the launcher *and* a
palette key bound. A left click skips a step. Left at its default `null`,
`tour.steps` keeps the built-in lap; an **empty** list is a type error, and the
off switch is `tour.enable = false`.
**Write keys as `{palette}`, `{leader}` and `{leaderName}`, never as a literal
chord.** They expand to what *that* machine resolved, so a tour you share still
names the right keys on a host that moved `keys.palette`, where a hardcoded
`⌘Space` would be wrong invisibly, since you never see the consumer's bar. A
misspelled placeholder renders as typed, and the rebuild warns and names it.
A rebuild warns when a step needs a room the desktop switched off, but those
warnings live inside sill's own module, so a desktop that turns the bar off gets
none of them. Check that pairing by eye.
## Prove the boundary [#prove-the-boundary]
Run the same structural check haus runs against its own desktops:
```sh
nix eval --impure --expr \
'(builtins.getFlake "github:hausfold/haus").lib.checkDesktop ./writer.nix'
```
It prints `true`, or throws naming exactly what escaped: a stray top-level key,
an `imports`, a merge or priority instruction, an option that isn't a haus
option, or a leaf that is host-only because it names a person or a piece of
hardware. Every rule has a fixture behind it, so the message names the file and
the option rather than failing somewhere deeper.
If the file is an app **pack** (a data-only file narrowed to `haus.roster`, the
apps on a machine), run `lib.checkPack` instead; same idea, one level in.
`checkDesktop` proves the trust boundary and the option names. To prove the
*values* build a machine, select it from a real host and evaluate:
```sh
nix eval .#darwinConfigurations.myhost.system.drvPath
```
[`haus plan`](/docs/haus/reference/haus) is the stronger version of that last
step: it prints what a rebuild *would* change (packages, settings, files,
launchd jobs, casks) and changes nothing. It builds first, so it takes longer
than the eval above; it tells you correspondingly more.
## Leave room for the host [#leave-room-for-the-host]
Anything your desktop sets, a consumer overrides with a plain assignment in
their host file. That already works: the desktop's values arrive below a host's
in the priority ladder, so nobody needs `lib.mkForce` to disagree with you.
The exception is worth knowing before you set one:
Every leaf a desktop sets is carried in at the desktop's priority, **a list
included**, so a host that names `tour.steps`, `keys.leaderExtras` or
`snippets.matches` at all replaces yours rather than appending to it. Someone
who wants three of your four entries has to restate all four.
That is the deliberate choice: a host says something, and the host's value is
what you get. The alternative (lists concatenating) means a consumer can add
to your list but can never drop one entry from it.
**Attribute sets are the middle case.** `roster`, `workspaces` and
`pounce.items` are carried per key, so a host that names one app's field leaves
the rest of yours standing.
List what your desktop sets in its README, so restating it is possible without
reading the file.
## What doesn't belong in one [#what-doesnt-belong-in-one]
Keep these in a private host file, or in something honestly labelled as more
powerful:
* identity, secrets, tokens and personal account names;
* an app roster that reveals more than the desktop needs;
* raw macOS defaults outside `haus.*`;
* a `pkgs` value, or a `/nix/store` path;
* activation hooks and shell commands;
* a display UUID: `haus.displays.main` says "the screen this Mac is using"
without naming anyone's hardware.
`checkDesktop` refuses every one of them, so this list is what the error message
will be about rather than a set of rules you have to remember.
Naming a *package* is fine and doesn't need `pkgs`: options that take one have a
`packageName` sibling taking the nixpkgs attribute as a string, so a desktop can
change the mono font or add a tool without leaving the data-only surface. A
configuration that genuinely needs `pkgs`, scripts, or arbitrary nix-darwin
options is a **power module**, and carries a different trust model.
# Customize a desktop (/docs/haus/desktops/customizing)
A desktop ships a whole opinion, already wired. So customising it isn't
assembly: it's turning a handful of dials, and every one of them lives in the
same file.
## The one file you edit [#the-one-file-you-edit]
There are two halves to a haus machine, and they never mix:
* **haus** (`github:hausfold/haus`): the layer. Generic, no identity, upstream.
You never edit it to use it.
* **Your config** (`~/.config/nix`): a small flake that imports haus, plus
**one host file**, `hosts//default.nix`, where you live.
Everything on this page goes in that host file:
```sh
haus edit # opens it in $EDITOR
haus rebuild # builds first; a broken config never activates
```
It's a plain nix-darwin module, so whatever you write merges with what haus
already declares. Because it's a separate repo, `haus update` pulls a newer
layer without touching your identity, your apps or your overrides.
## Finding the knob [#finding-the-knob]
Three ways in, and none of them is reading a reference end to end:
* **Your own config already lists them.** A fresh install drops a
`hosts//options.nix` beside your host file: every `haus.*` option at its
default, one sentence each, **all commented out**. Uncomment what you want.
`haus options` regenerates it from the build you're actually running, so it
can't advertise something your machine hasn't got. If you've edited your
copy it writes `options.nix.new` beside it rather than overwriting your work.
* **One line, no editor.** `haus set theme.accent teal` writes the setting,
type-checks it and rebuilds in one go; `haus get` reads one back and `haus
reset` removes it. The [haus CLI reference](/docs/haus/reference/haus#changing-a-setting-without-opening-an-editor)
has the model.
* **Ask for it in words.** haus installs a skill that teaches a coding agent this
whole surface, generated from the revision you run; see [Changing your Mac
with an agent](/docs/haus/rooms/agent-rebuilds).
Nothing in `options.nix` does anything until you uncomment a line *and* `import`
it; `haus options` prints the `imports = [ ./options.nix ];` line to add if it's
missing. Commented-out is deliberate, and for two reasons: a file that spelled
every default out would pin every one of them against your desktop, and it would
freeze haus's own defaults against ever being retuned upstream. Uncomment what
you actually mean.
## The cookbook [#the-cookbook]
The tour of what's worth knowing. Each block is host-file Nix.
### Identity [#identity]
```nix
haus.git.name = "Ada Lovelace";
haus.git.email = "ada@example.com";
haus.git.signingKey = "6F7BD6F43A7C1420"; # "" disables commit signing
haus.git.org = "analytical-engine"; # the GitHub owner your work lives under
```
`org` is the odd one out: not who you are, but where you work. gh-dash reads it
to fill `⌘G`'s PR tabs with that owner's open, green, red and just-shipped work,
so an org rename is one word rather than one per tab. On its own it does
nothing: set `haus.hearth.ghDash.enable = true` alongside it, and keep the Git
pack on (`haus.developer.git.enable`); that's what installs `gh`, and turning
it off while the overlay is on stops the build.
### The look [#the-look]
```nix
haus.theme.accent = "sapphire"; # any of the 14 Catppuccin accent names
haus.wallpaper.style = "minimal"; # none | minimal | orbits | constellation | flow | bold
```
The accent recolours lazygit, fzf, yazi, Glow, the browser and the generated
desktop together, but **not** the terminal, and not the bar beyond its logo
pill. Single-file dotfiles that bake a palette at their own theme slot (Ghostty,
starship, tmux, bat, zellij) keep their built-in colour on purpose, and a flake
check fails the build if one of them ever starts following. `minimal` is the
desktop haus generates for you (a flat field in your
palette, the ⌂ mark at its centre, a bloom in your accent), and `"none"` keeps
the wallpaper you already have. The [Appearance room](/docs/haus/rooms/appearance) has the
whole palette and the desktop's own dials.
### The terminal [#the-terminal]
```nix
haus.hearth.editorName = "neovim"; # the ONE editor: installed, and $EDITOR
haus.hearth.hijackFileAssociations = false; # make that editor the default file opener
```
More in [the Development room](/docs/haus/rooms/development).
### Apps, windows and the launcher [#apps-windows-and-the-launcher]
```nix
haus.roster.slack = { key = "s"; name = "Slack"; cask = "slack"; };
haus.prowl.enable = true; # false: no tiling, no Caps-Lock remap
haus.pounce.enable = true; # false: ⌘Space stays Spotlight
haus.pounce.signingIdentity = # sign it, and its Accessibility grant survives rebuilds
"Developer ID Application: Ada Lovelace (ABCDE12345)";
```
The roster is the most common edit on the machine and has [a page of its
own](/docs/haus/rooms/apps).
### The bar [#the-bar]
```nix
haus.sill.enable = true; # false: the native macOS menu bar stays
haus.sill.items.elgato = true; # opt-in extras, see The bar
```
The optional pills are personal (an Elgato key-light toggle, a Harvest timer,
an agents readout), so they're off unless you ask. [The
bar](/docs/haus/rooms/bar) lists them.
### Homebrew behaviour [#homebrew-behaviour]
```nix
haus.homebrew.cleanup = "none"; # none | uninstall | zap: what a rebuild does to undeclared apps
haus.homebrew.autoUpdate = false; # brew update before each rebuild
haus.homebrew.upgrade = false; # upgrade outdated packages each rebuild
```
`cleanup` is `"none"` so nothing you installed by hand is ever removed; `"zap"`
gets you a fully declarative machine where an undeclared cask is uninstalled on
the next rebuild.
`autoUpdate` and `upgrade` are off so a rebuild is reproducible: it installs a
cask once, then leaves whatever version brew first laid down. That's why an app
can sit on an old version on a fully synced machine. Upgrade one on demand with
`brew upgrade --cask `, or set both to `true` and accept that two rebuilds
days apart can land different app versions.
## Turning rooms off [#turning-rooms-off]
Every room you can see has a switch, and turning one off leaves the rest
working:
| Set this to `false` | And you lose |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `haus.prowl.enable` | Tiling, the leader key; Caps Lock stays Caps Lock |
| `haus.sill.enable` | The custom bar; the native macOS menu bar comes back |
| `haus.pounce.enable` | The ⌘Space launcher |
| `haus.perch.enable` | The notch file shelf; the copied `/Applications/Perch.app` stays until you delete it |
| `haus.hush.enable` | The quiet switch: bar pill, palette command and `hush` CLI |
| `haus.collar.enable` | Touch ID for `sudo`, and back to the password prompt |
| `haus.developer.enable` | The Development room: language toolchains, the Git tooling, the CLI toolbelt. Sub-switches (`.git`, `.toolbelt`, `.languages`) take out one piece at a time |
| `haus.ai.enable` | The AI room: the agent clients, holt, and the lifecycle wiring around them |
| `haus.snippets.enable` | Text expansion |
| `haus.apps.videoPlayer.enable` | IINA; videos keep whatever handler they have |
The macOS defaults, the Homebrew policy and the `haus` CLI itself are the
foundation the rest stands on, and have no switch. Every room you can *see*
does, and each one is off in the [blank
desktop](/docs/haus/desktops/blank), so what's on your Mac is whatever your
desktop asked for plus whatever you did.
Turning **pounce** off later doesn't return ⌘Space to Spotlight: haus disables
Spotlight's shortcut with a one-way write, and nothing re-enables it. Put it
back yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts. (**hush** is
the opposite case: it *adds* a Do Not Disturb chord macOS ships disabled, so
switching the room off leaves a spare binding rather than taking one away.)
If what you want is tiling **without** its keyboard claims, don't switch the
room off: set `haus.keys.leader = "none"` and `haus.keys.windowNav = "none"`
instead, and prowl keeps arranging windows while Caps Lock stays Caps Lock. The
launcher has the same escape: `haus.keys.palette` takes `alt-space`,
`ctrl-space` or `"none"`, and since only `cmd-space` displaces Spotlight, moving
it is how you keep both. See
[rebinding](/docs/haus/rooms/windows#not-fond-of-these-keys).
## Disagreeing with your desktop [#disagreeing-with-your-desktop]
Your host file wins. Anything the desktop chose, you change with a **plain
assignment** (no `lib.mkForce`, no import order to reason about):
```nix
# the desktop set theme.accent = "mauve"; you'd rather have teal
haus.theme.accent = "teal";
haus.sill.enable = false; # and no bar, thanks
```
That's the ladder doing its job: rooms declare defaults, a desktop states its
choices above them, your host sits above both, and anything `haus set` writes
sits above that. Switching desktops is one line in your flake; see
[Choose a desktop](/docs/haus/desktops/choosing).
Your host wins **whole**. A list-typed option (`tour.steps`,
`keys.leaderExtras`, `snippets.matches`) that you name at all discards the
desktop's version rather than adding to it, so if you want most of what the
desktop had plus one of your own, write out all of them:
```nix
haus.snippets.matches = [
{ trigger = "@@"; replace = "ada@example.com"; } # the desktop's, kept
{ trigger = ";sig"; replace = "— Ada"; } # and mine
];
```
To take a list away entirely, use its room's switch: `haus.tour.enable =
false` rather than an empty `tour.steps`, which is a type error.
## Bigger type [#bigger-type]
`haus.appearance.largePrint = true` is one named answer to a whole-machine
question, and it pulls two complementary levers. `haus.ui.scale` moves haus's own
type together (the terminal, the palette, the bar, the Dock, Finder's sidebar,
the tiling gaps), and `haus.displays.main.uiScale` moves the *display* one step
toward larger text, which enlarges everything including apps haus knows nothing
about. They multiply, which is the intended effect and a bigger jump than
either alone. `hausdisp list` prints the persistent UUIDs if you want to name
one display rather than "main".
One limit is macOS's rather than haus's: **the menu bar's height.** There is no
setting for it, so the bar's type follows `ui.scale` only so far and then stops.
Making the whole bar bigger is what display scaling is for: it changes what a
point *means*, which is the only thing that band responds to.
## Beating a default haus set [#beating-a-default-haus-set]
haus sets Dock, Finder, trackpad and keyboard defaults, and every one is a
*soft* default: set the same key plainly in your host file and yours wins.
```nix
# haus hides the Dock and puts it on the bottom. Put it on the left, always shown:
system.defaults.dock.autohide = false;
system.defaults.dock.orientation = "left";
```
Anything under [`system.defaults`](https://nix-darwin.github.io/nix-darwin/manual/)
is fair game.
Two menu-bar keys track whether the bar is on, so haus sets them plainly rather
than as `mkDefault`s. **`_HIHideMenuBar`** is the auto-hide: set it
yourself and you get a *conflicting definition* and a failed evaluation, not a
quiet win. That is the good outcome, because forcing it while the bar is on
puts two bars in the same strip of pixels. **`SLSMenuBarUseBlurredAppearance`**
(System Settings ▸ Menu Bar ▸ "Show menu bar background") haus rewrites on every
activation, because Tahoe's Liquid Glass made the hidden menu bar's hover-reveal
see-through; flip it by hand and the next rebuild flips it back.
One macOS ignores outright: **`NSGlobalDomain.AppleInterfaceStyle`** (light or
dark). It is a real nix-darwin option, and on macOS 26 writing it does nothing:
the key is where macOS *mirrors* the appearance it's showing, not a lever, so
even a plist read-back agrees with you while nothing changes. Use
`haus.theme.systemAppearance`; `haus diff` flags the other one.
And one that works, but not the way it reads: nix-darwin's own
**`power.sleep.computer`** and its neighbours run `systemsetup`, which has no
way to say *which* power source you mean: on macOS 26 one of them wrote the
**charger** profile while the Mac was on **battery**, and nothing said so. Use
`haus.power.*`, which says `battery` and `charger` separately.
## Secrets [#secrets]
Your config is text in a git repo, so secret *values* never go in it. haus
declares which secrets exist and fetches them at runtime through
[secretspec](https://secretspec.dev):
```sh
secretspec check # what's declared and still missing
secretspec set # fill one in
```
By default they live in this Mac's login keychain, which is why they don't ride
along when you clone the config onto a second machine. Point
`haus.secrets.provider` at a cloud vault if you'd rather they followed you.
If you'd rather share the repo than keep it private, add a
[gitleaks](https://github.com/gitleaks/gitleaks) pre-commit hook so a stray key
never gets to *be* a commit, and scan the history you already have **before**
you flip it public. Deleting a line later doesn't unpublish the key, so rotate
anything it finds.
```sh
cd ~/.config/nix
printf '#!/bin/sh\nexec gitleaks git --pre-commit --staged --redact --verbose\n' \
> .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
```
# everyday (/docs/haus/desktops/everyday)
**everyday** is the same care, aimed at a machine that will never open a
terminal. A quiet bar with the clock, the battery and the weather; a search box
on ⌘Space that opens anything; a wallpaper and a set of colours that agree with
each other. And **none of the developer tooling**: no compilers, no agents,
nothing installed for a job this Mac isn't doing.
It is a whole answer, not a stripped-down one. Windows behave exactly the way
they already do, because the fastest way to lose someone is to change what their
machine does when they click.
```sh
curl -fsSL https://hausfold.co/everyday.sh | bash
```
The URL picks the desktop, so the installer won't ask which one you want.
[Install](/docs/haus/install) has the rest.
This is not a five-second install: it asks for your password once, downloads a
few GB, and sets things up so it doesn't have to ask again. If you're doing it
for someone else, do it while sitting next to them.
## What's in it [#whats-in-it]
| | |
| --------------------------------------------- | --------------------------------------------------------------------------------- |
| **[the bar](/docs/haus/rooms/bar)** | A clock, battery and weather along the top edge: a better menu bar, not a new one |
| **[the launcher](/docs/haus/rooms/launcher)** | ⌘Space opens a search box that finds and opens things |
| **[the shelf](/docs/haus/rooms/shelf)** | A place in the notch for files on their way somewhere else |
| **[Focus](/docs/haus/rooms/focus)** | One key to go quiet, and one to come back |
| **[the look](/docs/haus/rooms/appearance)** | The haus mark on your palette as a wallpaper, and apps themed to agree with it |
| **a video player** | IINA, which opens the formats Quick Look won't |
| **a first lap** | One prompt, once: press ⌘Space, type, hit return. That is the whole tutorial |
## The judgement calls [#the-judgement-calls]
A desktop is a set of decisions somebody made on your behalf, so these are the
four worth seeing before you install it. Every one of them is a line in your own
file away.
**Tiling is off.** Tiling is good; remapping Caps-Lock to a leader key on
someone else's Mac is not. Windows keep the behaviour they already know, which
is also why this desktop claims no leader key at all.
**The launcher is on.** A search box that opens things is legible to anyone. It
is the one power feature that needs no explanation.
**The bar is on.** A clock, a battery and the weather is a better menu bar, not
a different way of thinking about the screen.
**Developer tooling is off, and agents with it.** Coding agents on a machine
that ships no coding tools is a room enabling itself for nobody. If you want
them, `haus.ai.enable = true` is the line.
This is the same idea rebuilt as a whole desktop rather than a layer over
another one, and two things it used to inherit are now deliberately off: the
coding agents, and the Node toolchain that came with them.
[`desktops/everyday.nix`](https://github.com/hausfold/haus/blob/main/desktops/everyday.nix)
states every difference in full.
## Where to go next [#where-to-go-next]
# hacker (/docs/haus/desktops/hacker)
**hacker** is a Mac arranged like a tiling Linux rig, but native to the grain
of the Mac: fog-grey, keyboard-first, and the same after you wipe the machine.
It is the desktop you get if you don't pick one, and the one most people meet
haus through.
You never edit the desktop to use it. The installer scaffolds a thin config of
your own at `~/.config/nix` that consumes it as an input, so your machine stays
yours and haus stays upstream.
```sh
curl -fsSL https://hausfold.co/nebelhaus.sh | bash
```
That URL picks this desktop, so the installer skips the question.
[Install](/docs/haus/install) has the rest: what the script does, how to read
it first, and the one command that actually changes the Mac.
## What it turns on [#what-it-turns-on]
haus is organised into [rooms](/docs/haus), each one a capability with its own
switch. hacker turns nearly all of them on.
| Room | What you get |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| **[Windows](/docs/haus/rooms/windows)** | Tiling launched at boot, and Caps-Lock as the leader key instead of a chord |
| **[Bar](/docs/haus/rooms/bar)** | Workspaces, weather, media, battery, clock, and a light that reddens when something has stopped |
| **[Launcher](/docs/haus/rooms/launcher)** | [pounce](/docs/pounce) on ⌘Space, where every command is a file you can write |
| **[Shelf](/docs/haus/rooms/shelf)** | [perch](/docs/perch), dropping out of the notch to catch a drag on its way somewhere else |
| **[Development](/docs/haus/rooms/development)** | Ghostty, zellij, zsh, a tinted prompt, helix and a toolbelt that all matches |
| **[Focus](/docs/haus/rooms/focus)** | One hotkey for Do Not Disturb, your Slack status, and your own hooks |
| **[Security](/docs/haus/rooms/security)** | Touch ID for `sudo`, inside a multiplexer too, and secrets declared rather than pasted |
| **[AI](/docs/haus/rooms/ai)** | Coding agents, each in its own checkout of the repo, so they never collide |
| **[Appearance](/docs/haus/rooms/appearance)** | The nebelung palette and a generated wallpaper, rendered onto twenty-odd tools at once |
Anything it chose, your own file overrides in a line; see [Customize a
desktop](/docs/haus/desktops/customizing).
## First moves [#first-moves]
The first switch has finished. Windows now tile themselves, the menu bar has
been replaced, your terminal opens into a themed session, ⌘Space belongs to the
palette, and `haus` is on your `PATH`. Here's the muscle memory.
| Keys | What happens |
| ------------------------ | ------------------------------------------------------------------------------------ |
| **⌘Space** | The palette. Type a few letters, hit Return. Start here for everything |
| **Tap ⇪, then a letter** | Launch or focus that app. `T` is the terminal, `B` the browser |
| **Tap ⇪, then an arrow** | Move focus between tiled windows. Keep arrowing; hold ⇧ to *move* the window instead |
| **Tap ⇪, then a digit** | Jump to that workspace. Add ⇧ to throw the current window there |
| **Tap ⇪, then /** | The cheatsheet, generated from *your* app list, so it is never out of date |
It's a leader key now, not a modifier. Tap and release, then press the letter.
Try these in order: tap **⇪ T** and the terminal opens and tiles itself;
**⌘Space**, type `saf`, Return and Safari launches; **⇪ then ← / →** jumps focus
between the two; **⌥/** flips the split between horizontal and vertical;
**⌘Space**, type `emoji`, Return turns the palette into an emoji grid.
One permission to grant: a few palette features use a keyboard path macOS gates
behind Accessibility. Everything else works without it.
```sh
pounce --request-accessibility
```
The rest of the keys are the rooms' own, and they are written out in full where
they're configured rather than copied here:
[Windows](/docs/haus/rooms/windows) has tiling, the leader and every workspace
throw, including how to move all of it onto keys you'd rather use;
[Development](/docs/haus/rooms/development) has the terminal's chord set;
[pounce](/docs/pounce/using) has the palette. There is also a live copy on your
own machine (tap **⇪ /**), generated from your own tables, so use that one when
they disagree.
## Making it yours [#making-it-yours]
Your settings live in one file, and `haus edit` opens it. The tweaks people
reach for first are an accent, an editor, and an app with a launcher key and a
workspace of its own.
```nix
haus.theme.accent = "sapphire"; # one accent, everywhere
haus.hearth.editorName = "neovim"; # named, so haus installs it
haus.roster.slack = { # an app, with a launcher key
key = "s";
name = "Slack";
appId = "com.tinyspeck.slackmacgap";
cask = "slack";
};
haus.workspaces.S = { # …and a workspace of its own
key = "s";
icon = ":slack:";
apps = [ "slack" ];
};
```
Then `haus rebuild`. [Customize a
desktop](/docs/haus/desktops/customizing) is the rest of what that file can say,
and [the haus reference](/docs/haus/reference/haus) is every command the CLI
has.
## Where to go next [#where-to-go-next]
# minimal (/docs/haus/desktops/minimal)
**minimal** is the terminal, finished (a tinted prompt, a themed toolbelt, and
the colours to match) on an otherwise **stock macOS**. No bar, no tiling, no
launcher, no shelf. Nothing that changes how the Mac behaves outside the window
you type in.
It is still a developer machine. Minimal here means **few rooms, not few
tools**: the toolbelt is the same one hacker ships. If what you want is a Mac
with no developer tooling on it at all, that one is
[everyday](/docs/haus/desktops/everyday).
```sh
curl -fsSL https://hausfold.co/minimal.sh | bash
```
The URL picks the desktop, so the installer won't ask which one you want; or
`nix run github:hausfold/haus#bootstrap` straight from the flake once Nix is
installed. [Install](/docs/haus/install) has the rest.
## What's in it [#whats-in-it]
Two rooms, plus the shell every desktop starts with; that one has no switch and
ships with every desktop, `blank` included, so it isn't counted here. Nothing
reaches past the terminal window. Each room is the same module hacker uses:
this desktop selects fewer of them, it doesn't use lesser ones.
| | |
| ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **[Development](/docs/haus/rooms/development)** | zsh, a tinted starship prompt, git, helix, the toolbelt with Node wired up; and zellij opens locked, so the multiplexer's own keys stay out of your way until you ask for them |
| **[Security](/docs/haus/rooms/security)** | Touch ID for `sudo`, and rebuilds that don't stop to ask twice |
| **[the theme](/docs/haus/rooms/appearance)** | The accent the palette is built from: mauve, until you say otherwise. Tuning, not a room |
Mono is set to 19pt, up from the room's 13: a deliberate default rather than a
leftover. This desktop *is* a terminal, and a terminal you read all day is worth
the size.
## What it deliberately leaves out [#what-it-deliberately-leaves-out]
Every one of these is absent for the same reason: it would reach outside the
terminal, which is the one promise this desktop makes. All of them are a line in
your own host file away if you disagree.
| | |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| **coding agents** | A room of their own; `haus.ai.enable = true` brings them here |
| **theme ports** | They write theme files into apps this desktop never installed |
| **the wallpaper** | The desktop picture is not the shell |
| **a video player** | An editorial app pick, and the one thing here you may miss from hacker; `haus.apps.videoPlayer.enable = true` brings IINA back in one line |
| **Focus, and the tour** | A quiet switch and a tutor both teach moves this selection doesn't ship |
## Where to go next [#where-to-go-next]
# Share a desktop (/docs/haus/desktops/sharing)
A desktop is one readable file, so sharing one is mostly about making it easy to
*read*, and about being honest in the README, which is the part the format
can't check for you.
## Publish something inspectable [#publish-something-inspectable]
The smallest useful repository is deliberately boring:
```text
writer-desktop/
├── README.md
├── writer.nix
└── LICENSE
```
For a first release, invite people to vendor the `.nix` file into their own
config rather than fetching it:
```sh
curl -O https://raw.githubusercontent.com/you/writer-desktop/main/writer.nix
mv writer.nix ~/.config/nix/desktops/
```
```nix
desktop = ./desktops/writer.nix;
```
A flake wrapper can come later if versioned imports turn out to be useful. It
should not hide the data people are being asked to trust, and any wrapper owes
every module it rebuilds a `_file`, or the conflicts it causes name no file at
all.
## What the README owes a reader [#what-the-readme-owes-a-reader]
Someone deciding whether to run your desktop is deciding what their Mac will
feel like tomorrow. Tell them:
* **who it's for**: the kind of person or the kind of work, in a sentence;
* **which rooms it turns on, and which it deliberately leaves off**, with the
reason for the off ones. That reason is the part they cannot reconstruct from
the file;
* **the strong opinions**: a claimed global hotkey, a remapped Caps Lock, a
changed default browser, anything that will surprise muscle memory;
* **which haus revision you tested against**;
* **what it deliberately doesn't handle**: permissions it expects the person to
grant, hardware it assumes, apps it doesn't install;
* **any list-typed option it sets** (`tour.steps`, `keys.leaderExtras`,
`snippets.matches`), because a host that names the same one
[replaces yours whole](/docs/haus/desktops/creating#leave-room-for-the-host)
and has to restate the entries it wants to keep;
* **that both `checkDesktop` and a real host evaluation passed**.
You do not need to document how to override you. Anything a desktop sets, a host
beats with a plain assignment. That is a property of the seam, and it holds for
every desktop without either of you doing anything.
## App packs are the neighbouring format [#app-packs-are-the-neighbouring-format]
If what you want to share is a *set of apps* rather than a whole machine, that's
a **pack**: a data-only file that sets nothing but `haus.roster`, imported
through `haus.lib.pack`. It lowers every field it sets, so a consumer's own
host wins with a plain assignment. A pack is not a desktop and doesn't compete
with one: a machine has one desktop and as many packs as it likes.
## Before you share [#before-you-share]
* [ ] `checkDesktop` prints `true`, and a real host evaluates.
* [ ] You have run it on a Mac, not only evaluated it.
* [ ] Every room it turns off agrees with the features that depend on that room,
`tour.steps` included.
* [ ] The README states the audience, the rooms, the strong opinions, the tested
revision and the limits.
* [ ] Any list-typed option it sets is named in the README.
* [ ] There is no identity, secret or device-specific data anywhere in it.
# Install (/docs/haus/install)
```sh
curl -fsSL https://hausfold.co/haus.sh | bash
```
That installs Nix if you don't have it, asks you a few questions (including
which desktop you want), and writes your config. It does **not** change your Mac on its own: it writes files, prints
the one command that activates them, and asks before running it for you.
So the whole install is two moves, and you can stop between them and read what
the first one wrote. (The pipe doesn't cost you the questions: the installer
reaches your terminal directly, so `| bash` interviews you like any other form.
In CI, a container, or a run whose output you've captured to a file, no one is
watching, so it takes the defaults and never offers to switch.)
## If you already know which desktop you want [#if-you-already-know-which-desktop-you-want]
Every desktop has its own URL, and typing it answers the question before it's
asked:
```sh
curl -fsSL https://hausfold.co/hacker.sh | bash # the full desktop
curl -fsSL https://hausfold.co/everyday.sh | bash # for someone who doesn't write code
curl -fsSL https://hausfold.co/minimal.sh | bash # just the themed shell
```
They're the same script: the URL sets the desktop and the installer skips that
one question. `haus.sh` is the one that asks. A Mac runs exactly one desktop,
and whichever you pick, changing your mind later is a line in your own config,
not a reinstall.
Piping to `bash` passes no arguments, which is why the desktop rides in the URL
rather than on a flag. If you'd rather use the flag, give `bash` a `-s --`:
```sh
curl -fsSL https://hausfold.co/haus.sh | bash -s -- --desktop=minimal
```
Every desktop is a set of values for the same options. Anything one of them
chose, your own file overrides in a line, so picking is a starting point, not
a commitment.
## Before you start [#before-you-start]
* A **Mac on Apple Silicon**, running a recent macOS. Intel isn't supported.
* **Xcode Command Line Tools.** The installer prompts for them if they're
missing, then asks you to re-run.
* **10–15 minutes** and a few GB of download for the first build.
The bootstrap installs [Determinate Nix](https://docs.determinate.systems/) for
you. If you already run stock Nix it stops and explains rather than touching
your install.
## Read it first, if you like [#read-it-first-if-you-like]
`curl … | bash` runs whatever the URL serves, so it's fair to want to look.
The URL only proxies
[`bootstrap.sh`](https://github.com/hausfold/haus/blob/main/bootstrap.sh) from
the public repo; it's boring on purpose.
```sh
curl -fsSL https://hausfold.co/haus.sh | less
# or pin an exact release and diff it against GitHub
curl -fsSL 'https://hausfold.co/haus.sh?ref=v2026.07.18' | less
```
A desktop's URL serves that same file with two lines added at the top, saying
which desktop you asked for. `haus.sh` adds nothing at all, so it's the one to
diff against GitHub.
By default you get the latest release tag. Releases are date-tagged, so
`?ref=v2026.07.18` pins a specific day, and a **release tag is the only thing
`?ref=` accepts**. Anything else is refused rather than fetched, which is what
stops a link with someone else's commit in it from serving you a script off
this domain.
## What it does [#what-it-does]
### Takes a snapshot [#takes-a-snapshot]
Confirms you're on macOS and takes a Time Machine local snapshot as a coarse
rewind point. Non-fatal if Time Machine is off.
### Asks you a few things [#asks-you-a-few-things]
Your git name and email, a desktop (hacker / everyday / minimal / custom), an
accent colour, a wallpaper, and an editor: helix, neovim, vim or nano, and haus
installs the one you name. It also asks whether to keep your current Dock,
keyboard and Finder settings, and whether to adopt the Homebrew casks you
already have.
### Shows you what would change [#shows-you-what-would-change]
A read-only report before anything is written: which dotfiles get backed up,
which macOS settings change, and which casks it found. **No app you installed
is ever removed.**
### Writes your config [#writes-your-config]
Creates `~/.config/nix/` (a small flake that imports haus, plus a host file
holding your answers) and commits it to git.
Nothing is active yet. Wherever there's a terminal to ask on, piped or not,
the last thing it asks is whether to change that: say yes and it runs the two
commands below for you, then `haus doctor`. Unattended, it stops here and prints
them.
## Build and switch [#build-and-switch]
```sh
cd ~/.config/nix
nix build .#darwinConfigurations.$(scutil --get LocalHostName).system \
&& sudo ./result/sw/bin/darwin-rebuild switch --flake .
```
The build runs first, and a failed build never touches your system. When it
succeeds, everything lands at once: apps install, the bar and tiling start,
your terminal is themed, and `haus` joins your `PATH`.
The first run is slow; it's downloading the world. After that, `haus rebuild`
is the only command you need.
## If you regret it [#if-you-regret-it]
| To undo | Use |
| ------------------- | ----------------------------------------------------------------- |
| The macOS settings | the snapshot from step 1 |
| Everything haus did | `haus rollback` (atomic, previous generation) |
| Apps | yours. haus never uninstalls one you added |
| All of it | [Leaving](/docs/haus/leaving), every exit in turn, smallest first |
## Unattended installs [#unattended-installs]
Every question has an environment variable, so the whole thing can run without
a prompt:
```sh
HAUS_NONINTERACTIVE=1 \
HAUS_GIT_NAME="Ada Lovelace" \
HAUS_GIT_EMAIL="ada@example.com" \
HAUS_ACCENT="mauve" \
bash -c "$(curl -fsSL https://hausfold.co/haus.sh)"
```
| Variable | Effect |
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `HAUS_NONINTERACTIVE=1` | Skip the questions, take the defaults |
| `HAUS_DRY_RUN=1` | Print the config it would write, write nothing. Still asks the questions if you have a terminal; pair it with `--defaults` for a silent run |
| `HAUS_DESKTOP` | Which desktop to select (`hacker`, `everyday`, `minimal`, `blank`), or `--desktop=` on the command line. Setting it skips that question even in an interactive run, which is what a desktop's own URL does for you |
| `HAUS_ROOMS` | Which rooms to turn on, one by one, instead of taking a desktop's selection |
| `HAUS_ACCENT` / `HAUS_WALLPAPER` / `HAUS_EDITOR` | The look and the editor |
| `HAUS_KEEP` | macOS settings to leave alone (`dock,keyboard,finder`) |
| `HAUS_DIR` | Scaffold somewhere other than `~/.config/nix` |
| `HAUS_FROM` | Restore an existing config instead of scaffolding |
Every variable above was spelled `NEBELHAUS_*` until 2026-08-14, and **the old
spelling is still read**: the installer promotes it before anything else runs,
with the new name winning if both are set. Same for the desktop names it
accepts: `hacker` was `nebelhaus`, and the pre-desktops `HAUS_PRESET=full` still
selects it. Nothing you already automated needs changing.
These are also what you want if you hand the install to a coding agent: the
questions are a terminal UI, and a tool driving it blind is how someone else's
git identity ends up in your config. An agent can do everything up to the
build; the two `sudo` steps are yours.
## Next [#next]
# Contributing (/docs/haus/internals/contributing)
haus is one repo in a family of them, and a change usually has to travel between
several before you can feel it. This page is that path. To *use* haus you need
none of it; [Keeping it current](/docs/haus/keeping-it-current) is that
page.
## The workshop [#the-workshop]
The [workshop](https://github.com/hausfold/workshop) is a parent directory that
holds every family repo checked out side by side, plus a `bench` dev CLI:
```sh
git clone https://github.com/hausfold/workshop.git
cd workshop
./bench clone
```
You end up with `haus/`, `nebelung/`, `pounce/`, `perch/`, `holt/` and the rest
as independent git repos, with `bench` sitting above them.
## Where a change goes [#where-a-change-goes]
Every change belongs to exactly one repo, and a colour hex in haus or launchd
logic in the launcher is in the wrong one even when it works:
| Change | Repo |
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| macOS defaults, tiling, the bar, the shell, Touch ID | [`haus`](https://github.com/hausfold/haus) |
| colours, the palette, how a tool is themed | [`nebelung`](https://github.com/hausfold/nebelung) |
| the launcher app, or a generic command script | [`pounce`](https://github.com/hausfold/pounce) |
| the notch file shelf | [`perch`](https://github.com/hausfold/perch) |
| how agent worktrees are made, parked or reaped | [`holt`](https://github.com/hausfold/holt) |
| the quiet notification banners | [`trill`](https://github.com/hausfold/trill) |
| the Homebrew formula and cask | [`homebrew-tap`](https://github.com/hausfold/homebrew-tap) (CI-owned; a hand-edit is overwritten) |
| `bench` itself | the workshop |
| these docs | [`hausfold.co`](https://github.com/hausfold/hausfold.co), and the workshop's `web/` until nebelhaus.com retires |
| your own machine's apps, identity, secrets | `~/.config/nix`, which is yours |
Four command-line tools come out of that, and only the first is for people who
merely *use* haus:
| Command | Reach for it to… | Ships in |
| -------------- | ------------------------------------------------------------------------------------- | ------------------------------------- |
| **`haus`** | drive your own Mac: [rebuild, update, roll back, diagnose](/docs/haus/reference/haus) | haus, always |
| **`holt`** | manage [agent worktrees](/docs/haus/rooms/ai) in any git repo | haus, with `haus.ai.enable` on |
| **`zscratch`** | feel-test a zellij edit with no rebuild | haus, with `haus.developer.enable` on |
| **`bench`** | move a change across the family repos | the workshop (contributors only) |
`haus` knows only *your machine*; `bench` knows only *the family repos*.
## Try it without pushing [#try-it-without-pushing]
You never have to push to *see* a change. `bench try` builds your real machine
config against the **local checkouts**, uncommitted edits and all, using Nix's
`--override-input`:
```sh
./bench try # does it build? nothing pushed, nothing activated
./bench try switch # run it on this Mac, still nothing pushed
```
When you're happy, commit in the repos you touched and let `bench ship` push
upstream→downstream, bumping a lock at every hop so the change actually reaches
your Mac without you hand-walking the
[pin chain](/docs/haus/internals/flakes#why-a-pin-means-nothing-changes-until-you-say-so).
It refuses a dirty tree, and fast-forwards each checkout from origin first: a
lock computed off a stale `main` pins the pre-merge commit and reports success.
`./bench status` opens with what this Mac is actually running, then every repo's
git state and every stale lock edge. An edge can also be **off-main**: pinned at
a rev that isn't on that repo's `main`, which is what a `nix flake update` inside
an open PR leaves behind. It resolves until the branch is deleted on merge, and
then the downstream repo can't fetch its input at all, so land the upstream PR
*first*, then ship. `./bench rebuild` is the way back to the pinned build.
## Working with agents [#working-with-agents]
The family is built to be hacked on by several coding agents at once, each in
its own worktree. That mechanism is [the AI room](/docs/haus/rooms/ai) and
it works for any repo; two things are specific to a *family* one.
* **`bench try` is worktree-aware.** Run it from inside a worktree and that
repo's override points at *your branch*, so a branch can prove it builds before
anyone merges it. It substitutes one lane, and only for a repo in the ripple
chain; from a *workshop* worktree it overrides nothing. When a change spans
repos, `bench try lane` builds your lane together with every `holt child` lane
spawned from the same pane, in one rebuild and with no PR.
* **`bench try switch` works from a worktree too**, and is the only way to feel
one unmerged branch alone. The gate is on **who, not where**: a person at the
keyboard runs it, an agent is refused. Activation is machine-wide and serial,
so parallel agents would overwrite each other's Mac. Every switch leaves a
receipt `bench status` reads back.
## Feel the whole queue at once [#feel-the-whole-queue-at-once]
Activating a Mac is serial, so a stack of open PRs can normally only be felt one
at a time, and merging them first to save the trouble puts unverified code on
`main`. `bench try-batch` inverts that: per repo it builds a throwaway
integration tree (`main` plus every open PR merged in), overrides the flake
there, and builds or activates the whole queue in **one** rebuild, `main`
untouched.
```sh
./bench try-batch # build every open PR together, with a tick-off checklist
./bench try-batch switch # and activate the combined tree on this Mac
```
Then merge only the ones that passed. Test-then-merge, not merge-then-test.
A PR that conflicts with the batch, or whose head branch isn't on origin, is
**excluded** and named in a footer under the checklist. Tick the checklist off
without reading that footer and you'll believe you felt a PR the batch never
built.
## Feel-testing a zellij edit [#feel-testing-a-zellij-edit]
A zellij plugin `.wasm`, a patched binary, or a layout change to tabs that
already exist can't hot-reload (a running server holds its plugin wasm in memory
for its whole lifetime), so seeing one means restarting the server and losing
every open tab. `zscratch` boots your candidate as a throwaway session in its own
window instead, rendered over a copy of the live `~/.config/zellij`:
```sh
zscratch --config config.kdl # a candidate config.kdl
zscratch --layout my-layout.kdl # …or a layout
zscratch --theme nebelung.kdl # …or a theme
zscratch --plugin tab-bar=tab-bar.wasm # …or a freshly built plugin
zscratch --bin /path/to/zellij # …or a binary-level patch
zscratch clean # kill the scratch session and temp dir
```
A plain `config.kdl` edit needs none of this: zellij applies most of that file
to the *running* server in about a second, so `bench try switch` is enough and
your tabs, panes and live agent sessions stay put.
## The whole life of a change [#the-whole-life-of-a-change]
```text
hack ─► test ─► assure ─► PR ─► batch-test ─► merge ─► try switch ─► ship ─► release
```
1. **hack**: in place, or on `worktree-*` branches in parallel.
2. **test**: `./bench try`, which from inside a worktree builds *that* branch.
3. **assure**: hand `git diff main...HEAD` to a clean-context reviewer that has
read nothing but the diff and the repo's `AGENTS.md`. Advisory, and it catches
what only bites after merge: a change in the wrong repo, docs left stale by a
renamed option, a colliding hotkey.
4. **PR**: against `main`, with a **What / Why / Verify / Watch-out** body.
Never a direct push or a local merge.
5. **batch-test**: `./bench try-batch`, the whole queue in one rebuild.
6. **merge**: the ones that passed; leave the rest open.
7. **try switch**: on `main`, now that it holds the work.
8. **ship**: `./bench ship` ripples the locks in dependency order.
9. **release**: `./bench release `; CI does the rest.
Three repos take CalVer (the launcher, the shelf and haus), and their version
is never typed by hand. `bench release ` stamps today's date
(`YYYY.MM.DD`, or `-N` on a same-day repeat) into the repo's version source,
commits it and tags it; passing a version to a CalVer repo is refused. CI then
publishes the release, and for the two app repos bumps the formula and the cask.
The command blocks while CI runs, drawing the jobs live, exits non-zero on red
and fast-forwards your checkout on green; `--ship` ripples the new lock edge in
the same breath.
A haus release is what the install one-liner serves, so a user-visible haus
change isn't *out* until it's tagged. And **holt is the exception, forced**:
`bench release holt 0.2.0` takes a semver and refuses to run without one, because
its SDKs publish to registries where a number, once published, can never be
withdrawn.
Each repo carries its own `AGENTS.md` with the deep rules for its boundary;
start there when you open one.
## Built on [#built-on]
* [Nix](https://nixos.org) and [nix-darwin](https://github.com/nix-darwin/nix-darwin), the reproducible system
* [AeroSpace](https://github.com/nikitabobko/AeroSpace) for tiling; [SketchyBar](https://github.com/FelixKratz/SketchyBar) for the bar
* [Catppuccin](https://github.com/catppuccin), the framework nebelung is derived from
* the terminal stack: [Ghostty](https://ghostty.org), [zellij](https://zellij.dev), [yazi](https://yazi-rs.github.io), [helix](https://helix-editor.com), [starship](https://starship.rs)
haus, nebelung and pounce are MIT. **holt** is Apache-2.0, and **perch** is *fair
source*: [FSL-1.1-ALv2](https://fsl.software), which turns into Apache-2.0 two
years after each release ships. Read it, build it, change it for yourself, but
don't sell it as a competing product.
# How the flakes fit together (/docs/haus/internals/flakes)
Nothing about your machine changes until you ask for it, and no two machines
built from the same config disagree. Both of those come from the same place:
haus is a [Nix flake](https://nix.dev/concepts/flakes.html), your config is
another one that imports it, and every import is pinned to an exact commit.
## Your config is a thin consumer [#your-config-is-a-thin-consumer]
The installer scaffolds `~/.config/nix` as a flake that takes haus as an input
and calls its builder:
```nix
{
inputs.haus.url = "github:hausfold/haus";
outputs = { haus, ... }: {
darwinConfigurations. = haus.mkHaus {
username = "";
hostname = "";
host = ./hosts/;
};
};
}
```
(The input's name is yours to choose; it is a local handle and nothing outside
your flake reads it. `haus` is what the installer scaffolds; configs written
before 2026-08-14 say `nebelhaus`, after the desktop rather than the layer, and
keep working. Rename it only together with every use of it in the same file.)
Your choices live in `hosts//default.nix`, [the one file you
edit](/docs/haus/desktops/customizing). haus stays upstream, and you never
edit it.
## The builder [#the-builder]
`mkHaus` is the entry point. Given a user, a hostname and a host file it
returns a whole nix-darwin system with every room wired up:
```nix
haus.mkHaus {
username = "ada";
hostname = "fog";
host = ./hosts/fog; # your identity and your choices
system = "aarch64-darwin"; # Apple Silicon only
extraModules = [ ]; # more nix-darwin modules, or a desktop
}
```
Only `username` and `hostname` are required. `desktop` selects the one
[desktop](/docs/haus/desktops/choosing) this machine runs (the argument the
installer writes for you), and `extraModules` is the seam for any other
nix-darwin module, an app pack included. Eight of haus's modules (`den`,
`hearth`, `prowl`, `sill`, `collar`, `pounce`, `hush`, `secrets`) are also
exported on their own under `darwinModules`, alongside a `default` holding the
lot. Reach for one of those only with the builder still around it: a module
declares no options of its own and takes the username from `mkHaus`, so on
its own it doesn't evaluate.
## The inputs [#the-inputs]
The haus flake pulls together ten:
| Input | What it is |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `nixpkgs` | the package set, tracking unstable |
| `nix-darwin` | the macOS system modules |
| `home-manager` | your user environment |
| `catppuccin` | the theme framework nebelung derives from |
| `nebelung` | the palette |
| `pounce` | the launcher app |
| `perch` | the notch file shelf; this input tracks perch's default branch, but what gets *built* is the notarized release zip perch's own flake pins |
| `holt` | the worktree substrate, put on your `PATH` when `haus.ai.enable` is on |
| `zmx` | the session backend an agent lane's window uses when `haus.hearth.lanes.backend = "zmx"`; the one input that deliberately doesn't follow our `nixpkgs`, since it builds through zig2nix against a pinned Zig |
| `nix-index-database` | the index behind `nix-index` and `comma` |
## Why a pin means "nothing changes until you say so" [#why-a-pin-means-nothing-changes-until-you-say-so]
Every input is recorded in `flake.lock` as an **exact commit hash**, never as
"latest". That is what makes a rebuild reproducible: the same lock produces the
same system on any machine, on any day.
The flip side surprises everyone: pushing a change to the palette or to haus
changes **nothing** on your Mac until your `flake.lock` is bumped to the new
commit. A single colour change therefore has to walk the whole chain, each link
a lock pinning the exact commit of the one before it:
```text
nebelung ──► pounce ──► haus ──► ~/.config/nix ──► your Mac
```
You never walk it by hand. [`haus update`](/docs/haus/keeping-it-current#pulling-a-newer-haus)
moves your pin to the latest haus (which already has its own inputs pinned),
upgrades the family's Homebrew apps, and rebuilds. Walking the *upstream* half is
a contributor's job, and `bench ship` does it; see
[Contributing](/docs/haus/internals/contributing).
## Cold-boot safety [#cold-boot-safety]
Nix lives on its own APFS volume, and at cold boot macOS will try to launch
haus's three GUI agents (AeroSpace, SketchyBar and Pounce) before that volume
is mounted. haus wraps those three in a shim that waits for the Dock, Finder and
SystemUIServer first, then gives up after a minute and launches anyway. That is
why your bar and your tiling come back after a reboot rather than racing the
login.
# Keeping it current (/docs/haus/keeping-it-current)
Every change to a haus machine is the same two beats: **edit the file, run one
command.** Pulling a newer haus, undoing yesterday's change and rebuilding the
whole setup on new hardware are that same loop, started from different places.
## The everyday loop [#the-everyday-loop]
```sh
haus edit # your host file, in $EDITOR
# …change something…
haus rebuild # build first, then switch
```
`haus rebuild` **builds before it switches**, so a config with a mistake in it
never reaches the running system: you get a build error and a machine still
exactly as it was. For a one-line change, skip the editor:
`haus set theme.accent teal` writes the setting, type-checks it and rebuilds in
one go. The [`haus` reference](/docs/haus/reference/haus) has the rest.
haus ships a **Rebuild System** command into
the [launcher](/docs/haus/rooms/launcher) (⌘Space, type "rebuild"), which runs `haus
rebuild` in a floating terminal, and a **Nix Config** command that opens your
host file.
## Pulling a newer haus [#pulling-a-newer-haus]
```sh
haus update
```
haus lives upstream, pinned to an exact commit in your config's `flake.lock`.
**Nothing about your machine changes until you run this**: no background
updater, no surprise on a Monday. `haus update` moves that one pin to the latest
haus, upgrades the family's Homebrew apps alongside it, prints what changed
upstream, and rebuilds.
`haus status` says where you stand: your current generation, the revision you're
pinned to, and whether upstream has moved past it. If you want the machinery
behind that pin (what a flake input actually is, and why a pushed commit
reaches you only when a lock moves), that's
[How the flakes fit together](/docs/haus/internals/flakes).
## When a change goes wrong [#when-a-change-goes-wrong]
```sh
haus rollback # back to the previous generation, atomically
haus generations # what you can roll back to
haus doctor # health check, when it isn't obvious what broke
```
Every switch creates a generation, and `rollback` steps back to the last one in
seconds. When it's a symptom rather than a change (a dead bar, a launcher that
won't answer), [Troubleshooting](/docs/haus/reference/troubleshooting) has the
known quirks and the command for each.
A generation holds everything Nix wrote (packages, services, login agents), but
**not** the macOS preferences a rebuild set imperatively: the Dock, Finder, the
keyboard. `haus capture` before a risky change snapshots those, and `haus
revert-settings` puts the snapshot back.
## A second Mac [#a-second-mac]
Your config is a git repo, so a machine you already set up catches up the way
any checkout does, assuming you pushed it somewhere both machines can reach:
```sh
cd ~/.config/nix && git pull
haus rebuild
```
Both machines read the same pin from the same lock file, so they converge on the
same system rather than on roughly the same one.
## A new Mac, or a wiped one [#a-new-mac-or-a-wiped-one]
This is the payoff for describing a machine as text: a restore, not a weekend of
clicking through System Settings. It assumes one thing: that
`~/.config/nix` is **pushed somewhere you can clone it from**. If it isn't, that
is today's five-minute job, not next month's.
### Point the installer at your config [#point-the-installer-at-your-config]
The one-liner that scaffolds a fresh setup also restores an existing one: it
clones your repo into `~/.config/nix` and skips the interview entirely.
```sh
curl -fsSL https://hausfold.co/haus.sh | bash -s -- --from
```
On a brand-new Mac the Xcode Command Line Tools are missing, so the first run
opens Apple's installer dialog and stops. Approve it, then run the line again.
Determinate Nix it installs for you. (By hand, `--from` is nothing more than
`xcode-select --install`, [Determinate
Nix](https://docs.determinate.systems/), and `git clone`.)
### Build, then switch [#build-then-switch]
```sh
cd ~/.config/nix
nix build .#darwinConfigurations.$(scutil --get LocalHostName).system \
&& sudo ./result/sw/bin/darwin-rebuild switch --flake .
```
The installer prints this pair for you, with your hostname already filled in.
The first build downloads the world and takes a while; when it lands, `haus` is
on your `PATH` and the machine is yours again; `haus doctor` is the check that
it all came back.
Your config declares each machine by hostname, and a new one isn't picked up on
its own. Either build the old host by name (`--flake .#`) or copy
`hosts//` to `hosts//` and add it to `flake.nix` beside the first.
Your identity and app choices come along either way.
```sh
nix eval ~/.config/nix#darwinConfigurations --apply builtins.attrNames
```
### What comes back on its own [#what-comes-back-on-its-own]
That one switch restores every app in `haus.roster` that names a Homebrew cask
or formula or a nixpkgs package, plus the whole terminal, the macOS defaults
haus manages down to the Caps-Lock remap and ⌘Space, and the launchd agents
behind tiling, the bar and the launcher. On macOS Tahoe and later that last
group has a catch: **Background Task Management can quietly refuse to start
them on a fresh machine**, and `haus btm` prints the one thing to click.
App Store entries are the exception: installing from an `appStoreId` is opt-in
(`haus.appStore.install`), and even switched on it can only fetch apps your
Apple account already owns; a paid app has to be bought in App Store.app once,
by you.
### The bits Nix doesn't carry [#the-bits-nix-doesnt-carry]
A handful of things never enter your config, on purpose. Walk this list once per
machine:
* **Secrets.** Your config declares *which* secrets exist, never their values.
`secretspec check` lists what's still missing and `secretspec set ` fills
one in. By default they live in this Mac's login keychain, which is exactly why
they don't ride along in the clone; point `haus.secrets.provider` at a cloud
vault and they follow you instead, at the cost of one login to bootstrap it.
* **Commit signing keys.** Your config names the key *id*; the key *material* is
yours to import, along with whatever agent unlocks it.
* **Pounce's Accessibility grant.** macOS ties it to the machine, so run
`pounce --request-accessibility` once more. If you set
`haus.pounce.signingIdentity`, import that certificate into the new keychain
too; that's what makes the grant survive later rebuilds
([Pounce config](/docs/pounce/config)).
* **Apps you installed by hand.** They won't reappear. Reinstall the ones you
want, and consider moving them into a
[roster entry](/docs/haus/rooms/apps) so next time they do.
* **Logins and licences.** Nix restores the app, not your session; sign back
into the handful that need it, the App Store among them.
Every app you move off that list and into `haus.roster` is one less thing to
remember next migration. The goal is a Mac you could wipe on a Tuesday without
flinching.
That's not a restore, and the [install](/docs/haus/install) flow handles it
deliberately: your existing Homebrew casks are kept, dotfiles are backed up
rather than clobbered, your current macOS settings can be captured into the new
host file, and nothing activates until you say so. Run from a terminal, it
*offers* to build and switch at the end; piped, it just prints the command.
# Leaving (/docs/haus/leaving)
haus is opinionated, not possessive. Switching off one room, returning to an
earlier generation, removing the system layer and removing Nix are four
operations of very different sizes. **Start with the smallest one that solves
your problem.**
| What you want | Where to go |
| --------------------------------------------------- | --------------------------------------------------------- |
| Undo the last rebuild | [Roll back a generation](#roll-back-a-generation) |
| Undo the macOS settings a rebuild moved | `haus revert-settings` ([below](#roll-back-a-generation)) |
| Stop using tiling, the bar, the launcher, the shelf | [Switch off a room](#switch-off-a-room) |
| Remove the managed system, keep Nix | [Take down the system layer](#take-down-the-system-layer) |
| Remove Nix itself | [Remove Determinate Nix](#remove-determinate-nix) |
`~/.config/nix` is *your* machine described in text, not generated state.
Archive it first; a git remote is better, if it holds no secrets.
```sh
cp -R ~/.config/nix ~/.config/nix.archive
```
## Roll back a generation [#roll-back-a-generation]
Every successful switch makes a generation, and stepping back to one takes
seconds:
```sh
haus generations # what you can go back to
haus rollback # the previous one
haus rollback # a specific one
```
That restores everything Nix wrote: packages, agents, shell config, paths. It
does **not** touch macOS's own preferences, which a rebuild sets imperatively:
the Dock, Finder, the keyboard, the menu bar, file associations. Those have
their own pair of commands:
```sh
haus capture # snapshot the live settings before a risky change
haus revert-settings # put the last snapshot back, byte for byte
haus revert-settings list # every snapshot you've taken
```
If you never captured one, System Settings is the way back.
**The wallpaper is the sharpest case.** `haus.wallpaper.style` defaults to
`minimal`, so the desktop is stamped at every activation and macOS keeps no
record of the picture that was there before. Rolling back doesn't bring yours
back: re-pick it in System Settings **and** set `haus.wallpaper.style = "none"`,
or the next rebuild stamps over it again.
## Switch off a room [#switch-off-a-room]
Every room comes out independently, and the rest of the machine keeps working:
```nix
{
haus.prowl.enable = false; # AeroSpace tiling, the Caps-Lock leader, window bindings
haus.sill.enable = false; # SketchyBar: the top bar, and the bottom one if you run it
haus.pounce.enable = false; # the launcher daemon, ⌘Space, its Accessibility features
haus.perch.enable = false; # the notch file shelf, see the residue note below
haus.hush.enable = false; # the quiet switch: its bar pill, palette command and CLI
haus.collar.enable = false; # Touch ID for sudo, back to the password prompt
}
```
```sh
haus edit
haus rebuild
```
The native menu bar comes back on its own: haus rewrites that key at every
activation, so switching sill off is enough. Caps Lock comes back too, but on
the next **restart**: the remap is a `hidutil` property that lasts until you
reboot, so the session you rebuilt in keeps it. **Spotlight's
⌘Space** doesn't: pounce disables that shortcut with a one-way write and nothing
turns it back on, so re-enable it in System Settings ▸ Keyboard ▸ Keyboard
Shortcuts. hush leaves the reverse kind of trace: a Do Not Disturb chord bound
onto a shortcut macOS ships disabled, which stays bound until you clear it.
[Customize a desktop](/docs/haus/desktops/customizing#turning-rooms-off) has the
full table, plus the middle ground where prowl keeps tiling but gives your keys
back.
## Take down the system layer [#take-down-the-system-layer]
This removes nix-darwin's system integration and leaves Nix installed for
everything else that uses it. Run nix-darwin's own uninstaller:
```sh
sudo nix --extra-experimental-features "nix-command flakes" \
run nix-darwin#darwin-uninstaller
```
If that can't run, try the locally installed copy: `sudo darwin-uninstaller`.
**Read the plan before confirming.** It removes the managed system links, static
files and launchd services, then restores the Nix daemon the installer supplied.
Restart the Mac when it finishes.
Your config isn't made useless by this. Keep it if you might come back, or move
it out of the active path with `mv ~/.config/nix ~/.config/nix.archive`.
### Put your own dotfiles back [#put-your-own-dotfiles-back]
Home Manager preserves a conflicting file with a `.backup` suffix rather than
clobbering it, so the file you had before the install is usually still there:
```sh
find "$HOME" -maxdepth 5 -name '*.backup' -print
```
Typically `~/.zshrc.backup`, `~/.zshenv.backup`,
`~/.config/starship.toml.backup`, `~/.config/git/config.backup`, plus some
inside managed directories. Inspect each one, confirm the Nix-managed target is
gone, then move it back by hand. One exception to hunting for: zellij's
`config.kdl.backup` is cleared on every rebuild by design, so it won't be there.
Don't restore blindly. A backup is the version that existed at the *first*
conflict, which may be years older than what you'd want today.
### Apps stay yours [#apps-stay-yours]
haus never removes an app just because it left your config:
`haus.homebrew.cleanup` is `"none"`. Compare what you declared against what
Homebrew actually owns, and remove only what you no longer want:
```sh
brew list --formula
brew list --cask
brew uninstall
brew uninstall --cask # add --zap to take its data too
```
### Pounce installed on its own [#pounce-installed-on-its-own]
If you installed the launcher through Homebrew rather than as a haus room
(removing the *room* is just `haus.pounce.enable = false` above):
```sh
brew services stop pounce
brew uninstall pounce
brew untap hausfold/tap
brew untap nebelhaus/tap # only if you installed before the org was renamed
```
Homebrew keys a tap by its directory, so an install that predates the rename
leaves the old one behind unless you untap it by its old name. Your own commands
and config live in `~/.config/pounce`; keep or archive that separately, and
remove Pounce from **System Settings ▸ Privacy & Security ▸ Accessibility** if
it's still listed.
## Remove Determinate Nix [#remove-determinate-nix]
Removing Nix affects **every** Nix-managed project and tool on the Mac, not just
haus.
The Determinate installer ships its own uninstaller. Read the plan before
confirming, and restart when it's done:
```sh
sudo /nix/nix-installer uninstall
```
If it fails, follow Determinate's current macOS uninstall guide rather than
deleting pieces of `/nix` by hand.
## What to check afterwards [#what-to-check-afterwards]
No uninstaller can promise to reconstruct every preference that existed before
an install. **`haus revert-settings` is the real answer if you captured a
snapshot**; this list is for when you didn't:
* the Dock's autohide, position, size and animations
* key repeat, press-and-hold, and the trackpad's tap-to-click and three-finger drag
* Finder's extensions, hidden files, path bar and view style
* hot corners
* the native menu bar and its clock, and Caps Lock
* Spotlight's ⌘Space, which never comes back on its own, and the Do Not Disturb
chord hush bound, which stays bound
* the wallpaper
* where screenshots land and in what format, and the screen-lock delay
* alert volume and the startup chime (that one is firmware, and survives a reinstall)
* your keyboard input sources: haus declares them exhaustively, so layouts you
never listed were switched off
* the application firewall
* file associations, if `haus.hearth.hijackFileAssociations` was on
* the launcher's Accessibility grant, and Touch ID for `sudo`, including the
passwordless-`sudo` rule for rebuilds, which is the one with a security
consequence
The installer takes an APFS snapshot before it starts, but macOS purges local
snapshots within about a day, so by the time you're reading this it has almost
certainly gone. Treat it as luck, not as a plan.
Then confirm what's actually gone:
```sh
command -v haus || echo "haus is no longer on PATH"
command -v darwin-rebuild || echo "darwin-rebuild is no longer on PATH"
launchctl list | grep -Ei 'hausfold|nebelhaus|pounce|aerospace|sketchybar|sill|hush|sleepwatcher'
```
That pattern matters: haus runs more agents than the obvious three, under three
different label prefixes, and a narrower grep reports a clean machine while the
bottom bar and the hush watcher are still loaded.
A surviving AeroSpace or SketchyBar process isn't automatically residue: both
are Homebrew installs that haus only *launches*, so `brew uninstall` is what
actually removes them. And a few things nothing has a removal path for: an
activation step copied the shelf app into place, and state directories are
written as the machine runs, so no uninstaller takes either away:
```sh
ls ~/.local/state/haus ~/.local/state/nebelhaus ~/.local/state/pounce ~/.config/perch /Applications/Perch.app
```
## If something stays behind [#if-something-stays-behind]
A departure that doesn't complete is a bug worth reporting. [Open an
issue](https://github.com/hausfold/haus/issues) with your Mac model and macOS
version, whether Nix existed before haus, the path you followed, the exact
command and its output, and what remained. Strip tokens, keys and personal
files out of anything you paste.
# The haus CLI (/docs/haus/reference/haus)
`haus` is the command that drives your machine after install: it wraps the Nix
build and the activation you would otherwise run by hand, and joins your `PATH`
with the first successful switch. Run it with no argument and you get `haus
status`.
For the day-to-day shape of it, see
[Keeping it current](/docs/haus/keeping-it-current).
## Commands [#commands]
| Command | What it does |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `haus rebuild` | Build, then switch. Your everyday apply; a failed build never touches the running system. |
| `haus update` | Move the haus pin in `~/.config/nix/flake.lock` forward, upgrade the family's Homebrew apps, then rebuild. |
| `haus rollback [N]` | Return to the previous generation, or to generation `N`. |
| `haus generations` | List the generations you can roll back to. |
| `haus status` | The current generation, and how stale the pinned haus is. |
| `haus edit` | Open your host file in `$EDITOR`. |
| `haus set …` | Write a machine override, type-check it, rebuild once. |
| `haus set` | With no arguments: search every option this machine has, then pick or type the value. |
| `haus get [path]` | Print one declared value; with no path, list your overrides. |
| `haus unset …` | Set nullable options to `null`, then rebuild once. |
| `haus reset …` | Drop overrides and inherit the value underneath again. |
| `haus plan` | What the next rebuild would change, read-only. |
| `haus diff` | What the configuration you're *running* set, versus what macOS actually has right now. |
| `haus capture [cat…]` | Turn this Mac's current settings into config lines *and* a snapshot. |
| `haus revert-settings [snapshot\|list]` | Put a captured snapshot back. |
| `haus options` | Refresh `hosts//options.nix` (every `haus.*` option at its default, described and commented out) from the build this machine is running. |
| `haus doctor` | Health check: Nix, the Xcode CLT, the login agents, cask drift, Pounce's Accessibility grant, your secrets, agent readiness. |
| `haus btm` | On macOS Tahoe and later, check whether Background Task Management is blocking the login agents. |
| `haus tour [reset]` | Start the guided tour in the bar, or re-arm its first-run hint. |
## Changing a setting without opening an editor [#changing-a-setting-without-opening-an-editor]
```sh
haus set theme.accent teal
haus get theme.accent # teal
haus reset theme.accent # inherit the desktop's value again
```
**Only `haus.*` paths are writable this way.** The prefix itself is optional:
`theme.accent` and `haus.theme.accent` are the same path, and so is the old
`nebelhaus.` spelling, which still resolves everywhere with a warning on
rebuild. But a raw `system.defaults.*` or `homebrew.*` path, or an option that
doesn't exist, fails before any file is written. Those belong in your host file,
via `haus edit`. Values take the obvious shell form for strings and JSON for
booleans, numbers, lists and attribute sets.
`set` accepts as many pairs as you like and applies them in **one** rebuild:
```sh
haus set theme.flavor latte theme.systemAppearance flavor
```
That matters when one intent spans two options (light mode is haus's palette
*and* macOS's own appearance), because two calls would be two rebuilds with the
machine sitting half-switched in between. Multiple pairs are all-or-nothing:
one rejected value rolls the whole call back. `unset` and `reset` take lists for
the same reason.
The two undo verbs differ in one place. `reset` deletes the override, so
whatever your host file or desktop says underneath becomes effective again; a
path that had no override is reported and skipped rather than fatal. `unset`
writes `null` unconditionally, so it succeeds only where the option's type
admits `null`.
### When you don't know what it's called [#when-you-dont-know-what-its-called]
Run `haus set` with no arguments and it asks instead:
```sh
haus set
```
The first prompt is a search over every option this machine's pinned haus has:
the path and a line of what it does, both searchable, so `light mode` finds
`theme.flavor` as readily as `flavor` does. The second prompt is shaped by the
option's own type: a closed set of values (an enum, a boolean) becomes a list
with today's value under the cursor, and anything else is a text box holding the
current default. Then it confirms, and hands the pair to the same `haus set`
you would have typed: same type-check, same rollback, same single rebuild.
Tab completion knows the same list: `haus set `, and likewise for `get`,
`unset` and `reset`. Both read a static catalogue installed with your build, so
neither one evaluates your configuration to answer; they describe the revision
you have pinned, and `haus update` moves them with it.
### One key inside an option [#one-key-inside-an-option]
A path may go *inside* an option rather than merely up to it:
```sh
haus set sill.items.aiUsage true # one pill, not the whole bar
haus set displays.internal.uiScale larger-text
```
**Prefer the leaf.** Overrides are written with `lib.mkForce`, so naming the
enclosing set (`haus set sill.items '{"aiUsage":true}'`) forces the *whole*
set, and every pill you didn't name falls back to its own default.
A misspelled sub-option is refused before anything is written. Keys you invent
yourself (a display's UUID, say) are taken as given, and what sits under them is
checked instead. A path and one of its ancestors can't both hold an override:
the second is refused, naming the first and the `haus reset` that clears it.
### Where the setting lives [#where-the-setting-lives]
`haus set` writes an ordinary Nix module to
`~/.config/nix/hosts//settings/.nix` and stages it: a git-backed
flake ignores untracked files, so staging is what makes it take effect. The file
*is* the setting: no database beside it, safe to read, edit or commit. Neither
`set` nor `reset` commits or pushes for you.
The [launcher](/docs/haus/rooms/launcher)'s **Haus Settings** command is the same
mechanism behind three intent-sized buttons: *Make text bigger*, *Switch to
light mode*, *High contrast on*. The palette keeps no settings of its own; it
calls `haus set`.
## Looking before you leap [#looking-before-you-leap]
`haus rollback` rewinds everything Nix owns, atomically. It does **not** rewind
macOS's own preferences: those are written imperatively during activation, into
`com.apple.dock`, Finder, `NSGlobalDomain` and friends, and no generation holds
them. Four commands close that gap:
```sh
haus plan # what the next rebuild would change, before it changes it
haus diff # what the running config set vs what macOS has right now
haus capture # snapshot the settings a rebuild is about to move
haus revert-settings # put that snapshot back
```
`capture` before a risky change and `revert-settings` after is the
macOS-defaults equivalent of a generation rollback. It defaults to the Dock,
keyboard and Finder; name a plist domain to reach anything else.
Both read the built activation script itself, and both probe macOS live for the
accessibility keys it accepts silently and then ignores, so neither is trusting
a plist or a hand-kept list.
`plan` reports five things, and the middle one is the one people expect to be
missing: packages, macOS settings, **the files written into your home** (with
the `onChange` hooks each would fire), launchd jobs, and new casks. Most of what
a desktop changes is neither a package nor a settings key: switching a bar pill on
moves a config file and nothing else, which no closure diff would show. The
handful of files written by an activation script rather than linked (zellij's
`config.kdl` is the one you'll meet) stay outside that set and won't appear.
`haus` always evaluates `~/.config/nix` unless you tell it otherwise. Run `haus
plan` from a linked git worktree of that config (an agent lane, say) and it
previews the config on `main`, not the branch you're looking at. It warns and
names both paths; point it at the tree you mean:
```sh
HAUS_CONSUMER="$(git rev-parse --show-toplevel)" haus plan
```
## The one rebuild it refuses [#the-one-rebuild-it-refuses]
If your host file writes `system.defaults.universalaccess.*` directly, `haus
rebuild` refuses to run whenever the app it runs under lacks Full Disk Access:
that write would abort activation partway and take every background service down
with it. The grant belongs to the app, not to you, so this catches a plain
terminal nobody has granted just as surely as an agent pane, and lets an agent
pane inside a granted terminal through.
Sidestep it with `haus.accessibility.*`, which reaches every key in that domain
macOS actually honours (all seven, which since August 2026 is every key
nix-darwin types there) with a guarded write. Otherwise run the
command from an app that holds the grant, or set `HAUS_FDA_ANYWAY=1` when you
know better. `haus plan` says which grant a rebuild wants before it runs.
[Changing your Mac with an agent](/docs/haus/rooms/agent-rebuilds) has the
detail.
## The other CLIs [#the-other-clis]
`haus` is the only command an end user needs. Two more arrive with the rooms that
want them: `haus.ai.enable` brings **`holt`**
([the AI room](/docs/haus/rooms/ai)), agent worktrees for any repo, and
`haus.developer.enable` brings **`zscratch`**, for feel-testing a zellij edit
without a rebuild.
# haus.* options (/docs/haus/reference/options)
{/* GENERATED FILE — do not edit by hand.
Rendered from haus's own module system by scripts/gen-options.mjs.
To change an option's description, edit its declaration in haus and
regenerate:
node scripts/gen-options.mjs --haus /path/to/haus
CI re-renders this and fails if it differs, so a hand edit here is
guaranteed to be reverted. */}
These are the `haus.*` options you set in your host file at
`~/.config/nix/hosts//default.nix`. Everything here is optional
unless noted; the defaults are a complete, working system.
The page is grouped by **room** — the same rooms the sidebar is organised
around — and each room lists the `haus.*` namespaces it owns. A room
can own more than one: the Bar room is `haus.sill` (its own bar) *and*
`haus.menuBar` (macOS's).
Apply changes with `haus rebuild`. Each option lists its **type** and
**default** under its name, and links to the file that declares it.
## Apps [#apps]
The apps a finished machine has: the curated picks, the packs that switch a whole set on in one line, the file types they claim, App Store policy, and what a rebuild does to anything you installed by hand. The list they all land in is `haus.roster`, a shared surface below.
### haus.appStore [#hausappstore]
Whether a rebuild may install the roster's `appStoreId` entries. Off by default: it reaches the network and acts on your Apple Account, and it can never be complete — `mas` cannot sign in, and cannot buy a paid app.
#### `haus.appStore.install` [#hausappstoreinstall]
`boolean` · default `false`
Install roster entries that set `appStoreId` from the Mac App
Store during activation, skipping any already installed.
Off by default: this reaches the network and acts on your Apple
ID, which shouldn't happen as a side effect of turning on a
window manager. It also can't be complete — `mas` cannot sign in
(do that once in App Store.app) and cannot make a first-time
PURCHASE, so a paid app you don't already own is reported and
skipped rather than installed.
Deliberately NOT nix-darwin's `homebrew.masApps`: that runs
`mas install` through `brew bundle` as your user, and since
macOS 13 the App Store install path requires root — so it stops
for a password prompt that a rebuild has no terminal to show,
and the rebuild hangs. The activation step this option enables
is already running as root, so it neither prompts nor wedges.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
### haus.apps [#hausapps]
The apps haus picks for you, the saved collections you can switch on in one line, and the file types they claim — the ones a finished machine has rather than the ones a room needs to work. Each is one switch you can turn off; what it installs is a roster entry like any other, so you can retune or replace it by app id.
#### `haus.apps.packs.writing.enable` [#hausappspackswritingenable]
`boolean` · default `false`
Install the **writing** collection: Obsidian, Zotero, Anki and calibre —
a Mac that reads and writes rather than compiles.
These arrive as ordinary roster entries at `mkDefault`, so anything you
say about one of them in your own host file wins per FIELD and the rest
of the entry survives:
haus.roster.zotero.key = "y"; # a letter of your own
haus.roster.obsidian.appId = "…"; # osascript -e 'id of app "Obsidian"'
Two of them claim a leader letter (`o`, `l`, `k`) and none claims a
workspace — a workspace names its own members, so give one to Obsidian
in your host with `haus.workspaces`. The file is
`modules/apps/packs/writing.nix`, and it is readable data: four casks
and the keys to reach them.
Example:
```nix
true
```
Declared in
[`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)
.
#### `haus.apps.videoPlayer.claimFileTypes` [#hausappsvideoplayerclaimfiletypes]
`boolean` · default `true`
Make IINA the default handler for the everyday video extensions —
mp4, m4v, mov, mpg, mpeg, mkv, webm, avi, wmv, flv, 3gp, ogv, vob —
so double-clicking a video opens IINA instead of QuickTime Player, TV
or a browser. Ignored unless the player is installed.
A short list on purpose: it covers what you actually double-click,
not everything IINA can decode. Dead, professional and DRM'd
containers (qt, divx, asf, f4v, 3g2, ogm, rm, rmvb, mxf, dv, …) are
left alone — they still play via Open With, they just don't get the
default, and every extension haus claims is a binding it
re-asserts on every rebuild.
Video only. Audio (mp3, flac, m4a, wav, …), `.gif` and playlists
keep whatever owns them today, since "open videos in IINA" rarely
means "and my music library too". The transport-stream extensions
`.ts`, `.mts` and `.m2ts` are excluded too: on a developer's machine
they are TypeScript far more often than video, and
`haus.hearth.hijackFileAssociations` claims them for the editor.
Claiming them here as well made macOS stop and ask which app should
win on every single rebuild, because `.mts` and `.m2ts` share one
UTI.
This sets the USER default (via `duti`) — the same record Finder's
Get Info ▸ Change All writes, so it is undoable by hand. Set false to
install the app and leave every association alone.
Declared in
[`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)
.
#### `haus.apps.videoPlayer.enable` [#hausappsvideoplayerenable]
`boolean` · default `false`
Install IINA — haus's video player — as the roster entry `iina`.
A nixpkgs build, so it lands in \~/Applications/Home Manager Apps
rather than /Applications.
The nebelhaus desktop turns this on, because macOS ships QuickTime
Player, which refuses most of what you actually double-click (mkv,
webm, and anything not in Apple's codec list) — so "a video player
that plays videos" is part of what that desktop considers a finished
machine. The bare layer installs nothing you didn't ask for.
Set false and nothing is installed or rebound — bring your own
player via the pounce "Install App" palette command or a roster
entry. Once on it is a roster entry like any other: give it a leader
letter with `haus.roster.iina.key`, or pin a different build
with `haus.roster.iina.package`.
Declared in
[`modules/apps/options.nix`](https://github.com/hausfold/haus/blob/main/modules/apps/options.nix)
.
### haus.homebrew [#haushomebrew]
How rebuilds treat Homebrew packages you did not declare.
#### `haus.homebrew.autoUpdate` [#haushomebrewautoupdate]
`boolean` · default `false`
Run `brew update` before activating the Homebrew step on every
rebuild. Off by default — reproducible rebuilds shouldn't silently
pull newer formulae. Turn on if you want brew to track upstream.
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.homebrew.cleanup` [#haushomebrewcleanup]
`one of "none", "uninstall", "zap"` · default `"none"`
How `darwin-rebuild switch` treats Homebrew casks/brews that are
installed but NOT declared anywhere in your config.
* "none" (default, safe): leave undeclared formulae/casks alone.
haus never deletes apps you installed yourself.
* "uninstall": remove undeclared formulae/casks (keeps their data).
* "zap": remove undeclared formulae/casks AND their app data. Fully
declarative, but a stray cask you forgot to list is deleted — with
no backup — on the very next rebuild. Only choose this once every
app you keep is declared (bootstrap can adopt your current casks).
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.homebrew.upgrade` [#haushomebrewupgrade]
`boolean` · default `false`
Upgrade outdated Homebrew packages on every rebuild. Off by default
for the same reproducibility reason as autoUpdate.
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
## Appearance [#appearance]
How the machine looks: the palette and its accent, the wallpaper, the fonts, and the macOS surfaces that follow them — motion, screenshots, sound and the accessibility keys. The interface scale every room reads is `haus.ui`, a shared surface below.
### haus.appearance [#hausappearance]
The Appearance room's own profile — one named answer to a whole-machine question, where the groups below are the individual dials. `largePrint` sets the interface scale, the high-contrast palette, macOS's own contrast lift and the screen's scaled resolution together, each as a default you can still pin by hand.
#### `haus.appearance.largePrint` [#hausappearancelargeprint]
`boolean` · default `false`
Make everything haus controls bigger and sharper, in one line. Deliberately
NARROW: it is about seeing, not about who you are, so it says nothing about
which rooms you run — set it in any desktop, or in your host on top of one.
It moves four things, each as a DEFAULT, so pinning any single one by hand
still wins — with one asymmetry worth knowing: a DESKTOP that sets one of
the four beats this profile even when the profile was switched on in your
host, because the desktop's values sit above a room's defaults in the
priority ladder. Set the value itself in your host to settle it:
haus.ui.scale = 1.4 the terminal font (19 → 27 pt), the whole
command palette, the bar's type (to its
ceiling, see below), Dock icons 48 → 67,
Finder's large sidebar rows, prowl's gaps
haus.theme.contrast = "high" body text 11.3:1 → 19.9:1 against the
background, across every tool haus
colours. Measured in nebelung's CI.
haus.accessibility.increaseContrast = true
the same lift for NATIVE macOS apps, which
the palette cannot reach. FDA-gated at the
option, so it sharpens the result where it
applies and is skipped where it doesn't.
haus.displays.main.uiScale = "larger-text"
one step of the screen's scaled resolution
toward larger text. The only line here that
reaches apps haus has never heard of,
because it changes what a point MEANS.
`main` rather than `internal` on purpose: on a laptop they are the same
panel, but on a Mac mini or a clamshelled MacBook `internal` matches
nothing — so keying on the built-in panel would quietly do nothing on the
desk setup most likely to need this. Name a specific monitor by UUID in
your host file for per-display control; `hausdisp list` prints them.
What it does NOT move, stated here because a large-print machine that
quietly under-delivers is worse than one that says where it stops:
* macOS's own text-size setting. `universalaccess`'s FontSizeCategory
key stores a value and posts no change notification, so apps never
re-read it. Display scaling is the lever that works.
* Third-party apps' own font settings. Nothing outside haus follows
`ui.scale` — they follow the display, which is why the line above
matters.
* The perch shelf, which sizes itself from the screen because it hangs
off the notch. Scaling the display shrinks its width in points by
exactly the factor that makes a point bigger.
* The menu bar's HEIGHT. Its type grows to a 1.25x ceiling and stops:
36pt of bar with 28pt pills is what keeps them inside the 32pt band
macOS's own hover-reveal covers, and that band is macOS's. The lever
that does move it is the display scaling above.
* A more legible FONT FAMILY, which is a choice rather than a limit.
A typeface is taste and a legibility profile should not decide yours;
Atkynson Mono is Atkinson Hyperlegible's monospaced sibling, drawn by
the Braille Institute for exactly this problem:
haus.fonts.mono.packageName = "nerd-fonts.atkynson-mono";
haus.fonts.mono.name = "AtkynsonMono Nerd Font";
* Light mode, if it reads better for you: `haus.theme.flavor = "latte"`.
Example:
```nix
true
```
Declared in
[`modules/appearance/options.nix`](https://github.com/hausfold/haus/blob/main/modules/appearance/options.nix)
.
### haus.theme [#haustheme]
Colour: the palette's flavour and contrast, the accent every themed tool spends, and whether macOS's own Light/Dark follows it.
#### `haus.theme.accent` [#hausthemeaccent]
`one of "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `"mauve"`
The accent colour, by Catppuccin name (the Nebelung palette is a
grey-tinted Catppuccin, so the fourteen names are the same in both
flavors — the hue you pick follows haus.theme.flavor). It recolours
the tools nebelhaus injects colours into — lazygit, fzf, yazi (including
glow-rendered Markdown headings), and the Zen browser — via the matching
Nebelung per-accent ports.
perch follows it too, and is the one surface handed the NAME rather than
a hex: the shelf resolves it against whichever half of its dark/light
pair macOS is showing, so the ember under the notch and a pinned tile
wear this accent in both polarities from one key. Left at perch's
default it accents with its own mark green.
Three more things follow it: the generated desktop (the bloom behind
the mark in `minimal`, and the whole sweep in `bold` — see
haus.wallpaper.style), any roster app whose
Nebelung port ships a per-accent matrix (zed, gh-dash, mpv), placed by
haus.theme.ports, and the bar's far-left logo pill. Those ports name the
theme file after the accent, so changing the accent renames the file the
app's own `theme` key points at — re-pick it in the app, or it falls
back to stock.
The bar is the newest and the narrowest of the three: `haus.sill.logo`
is the ONLY pill that follows this option. Every other colour on the bar
is a fixed palette key, and the palette itself doesn't move — so a
machine that changes its accent sees exactly one pill change hue, unless
`haus.sill.logo.color` names one of its own.
Honest scope: this moves the accent on those tools, NOT literally
everything. Single-file dotfiles that bake the palette at their own
theme slot (ghostty, starship, tmux, bat, zellij, …) keep their built-in
colour and don't follow this option. The base palette stays the same
Nebelung grey either way — only the accent hue changes.
Zen means Zen's own UI, and the web is a separate story. haus places
the Nebelung userChrome/userContent pair, but userContent only styles
`about:` pages — github.com and youtube.com are themed by the Stylus
extension, whose Catppuccin-derived styles carry their OWN `accentColor`
var (default mauve) inside the extension's storage, where no stylesheet
can reach it. Declare `haus.zen.extensions.stylus` and haus
stamps that var with this accent and tells you, once, when there's a new
bundle to import; the import itself stays a click, because Stylus has no
file interface. Until you make it, the web keeps the accent you last
imported.
Both halves of that are pinned by the `accent-reach` flake check, which
fingerprints every surface under three accents and fails if one starts
or stops following the accent without anyone deciding it should.
Example:
```nix
"sapphire"
```
Declared in
[`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)
.
#### `haus.theme.contrast` [#hausthemecontrast]
`one of "normal", "high"` · default `"normal"`
How far the interface separates from its background.
"high" swaps in the Nebelung high-contrast palette: the same hues and
the same accents, with the neutral ramp pulled apart in OKLCH so text
and background separate further at every step. Measured rather than
eyeballed — body text goes from 11.3:1 to 19.9:1 against the base,
clearing WCAG AAA (nebelung's own CI asserts it).
Composes with `flavor`, and the boost is tuned per flavor rather than
shared: light mode has far less room above its background before the ramp
clips to white, so latte goes 7.0:1 → 9.9:1 where mocha goes 11.3 → 19.9.
Both keep all twelve ramp steps distinct, which is the property nebelung's
tests actually assert.
Honest scope. This recolours what haus injects colours into:
Ghostty, bat, delta, lsd, yazi, zellij, glow, starship, lazygit, the
bar, pounce and perch (at runtime, via \~/.config/\{pounce,perch}/themes/ —
and unlike `flavor`, contrast reaches both on BOTH halves of their
light/dark pair), Zen and Obsidian. It does NOT reach:
* macOS itself. For system-wide contrast see
haus.accessibility.increaseContrast — a separate, FDA-gated
setting. The two are complementary, and a genuinely high-contrast
machine wants both.
Example:
```nix
"high"
```
Declared in
[`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)
.
#### `haus.theme.flavor` [#hausthemeflavor]
`one of "mocha", "latte"` · default `"mocha"`
Light or dark. "mocha" (the default) is the dark half of Nebelung,
which is what haus has always shipped; "latte" is light mode.
Not an inversion of the dark palette — a different SOURCE palette. Nebelung
is "Catppuccin with the blue stripped out", and those rules say nothing
about dark, so they apply to Catppuccin Latte just as well: same warm-grey
neutral ramp, same calmed accents, the other polarity. Light mode lands at
7.0:1 for body text on its own, so it's legible before you reach for
contrast = "high" (which takes it to 9.9:1).
It composes with `contrast`: the two axes give four palettes, and nebelung's
CI measures each one's contrast ratio rather than eyeballing it.
Honest scope, in two parts.
What follows it: every tool haus injects colours into or points at a
rendered theme — Ghostty, bat, delta, lsd, yazi, fzf, glow, starship,
lazygit, zellij, opencode, the bar, Zen and Obsidian, plus helix
whenever it is the editor `haus.hearth.editorName` selects (Nebelung
has a port for helix and none for the alternatives).
These are genuinely re-rendered for the flavor, not recoloured in place:
whiskers takes different branches for a light flavor (terminal ANSI
0/7/8/15 swap, Zen switches its prefers-color-scheme block, delta sets
`light = true`).
What does NOT follow it:
* pounce and perch, by default. Both read their palette at runtime and
can pick per polarity, so haus.pounce.followSystemAppearance
and haus.perch.followSystemAppearance (default true) hand that
choice to macOS Light/Dark instead: haus installs every rendered
variant into \~/.config/\{pounce,perch}/themes/ and writes the
dark/light PAIR at your `contrast`. Set either option false to pin
that app to this flavor like everything else.
* macOS's own Light/Dark appearance, unless you opt in with
haus.theme.systemAppearance = "flavor". Left at its default haus
does not touch system appearance in either direction, so latte on
a dark macOS looks half-done and that half is yours —
except in pounce and perch, which read the appearance themselves.
* three of the six desktops (haus.wallpaper.style). The hand-made
"orbits", "constellation" and "flow" have the dark palette baked into
their pixels; "bold" is generated but follows theme.accent rather
than the flavor. "minimal" DOES follow it, in every part — field,
mark, glow and debug band.
Example:
```nix
"latte"
```
Declared in
[`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)
.
#### `haus.theme.ports.enable` [#hausthemeportsenable]
`boolean` · default `false`
Theme the apps in your roster (`haus.roster`) that Nebelung ships a
port for, without wiring each one by hand.
haus already themes every tool it installs itself — the shell, the
terminal, the git stack, Zen, Obsidian. This covers the other direction:
an app YOU added to the roster that Nebelung happens to have a theme for.
Add `zed`, `warp` or `xcode` to `haus.roster` and its Nebelung theme
lands where that app looks for themes, in the flavor and contrast you
selected, following them on every rebuild. Matching is by roster id, so
the entry has to be named after the port (`zed`, not `zed-editor`).
Honest scope, and it is the whole point of the option: this drops the
theme FILE. Whether that alone makes the theme *active* is the app's
choice, not ours, and Nebelung records which is which per port. Ghostty
reads a config key we own, so it just works. Xcode, Warp, OBS and friends
offer no file interface for picking a theme — the file is put where they
look, and the one click that selects it stays yours. `haus doctor` lists
exactly which apps are waiting on that click, so the difference is
visible rather than something you discover months later.
Ports whose install is a merge into an existing config file, or that need
a compile step first, are reported but never written: silently
half-applying someone's config is worse than saying so.
Declared in
[`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)
.
#### `haus.theme.systemAppearance` [#hausthemesystemappearance]
`one of "unmanaged", "flavor", "light", "dark"` · default `"unmanaged"`
Whether haus also sets macOS's OWN Light/Dark appearance — the one
in System Settings ▸ Appearance, which paints Finder, the menu bar and
every native app haus can't reach.
unmanaged (default) leave it alone, in both directions. Your Mac's
appearance stays yours; nothing about a rebuild moves it.
flavor follow haus.theme.flavor — latte sets Light, mocha
sets Dark. This is the one that makes light mode complete
rather than half-done.
light pin Light, whatever the flavor is.
dark pin Dark, whatever the flavor is.
Default "unmanaged" on purpose: a managed default would silently revert
an appearance you picked in System Settings on the next rebuild, which
is a worse surprise than a half-light machine.
How it is applied, and why it is not a `system.defaults` key. Measured
on macOS 26.6 (2026-08-08), NOT recalled from docs:
`NSGlobalDomain.AppleInterfaceStyle` is INERT in both directions. Writing
"Dark" from a light session does nothing; deleting the key from a dark
one does nothing; `activateSettings -u` does not help; a process launched
fresh afterwards still reports the old appearance, and no
AppleInterfaceThemeChangedNotification is posted. That key is a mirror
the appearance system writes, not a lever. So haus drives appearance
through System Events (AppleScript) at each home-manager activation,
which does flip it live in \~0.3s — and confirms the result with `hausax`
(AppKit's effective appearance), never by reading the key back.
Reachability, the same shape as haus.accessibility.increaseContrast:
driving System Events needs an Automation grant for whichever app runs
the rebuild (System Settings ▸ Privacy & Security ▸ Automation). Without
it macOS refuses, the rebuild says so in a named warning and carries on
— the appearance just doesn't move, and nothing else is affected.
One more thing macOS can undo: System Settings ▸ Appearance ▸ **Auto**
switches polarity on its own schedule. haus sets the appearance at
rebuild time and does not fight it afterwards, so on an Auto machine
this option holds only until the next scheduled switch. Pick Light or
Dark there if you want it to stick.
Interaction worth knowing: haus.\{pounce,perch}.followSystemAppearance
hand polarity to macOS. Set this to "flavor" and macOS's polarity is in
turn haus's, so those two end up following `flavor` transitively —
which is usually what you wanted, but it does mean `followSystemAppearance`
stops being an independent axis on this machine.
Example:
```nix
"flavor"
```
Declared in
[`modules/theme/options.nix`](https://github.com/hausfold/haus/blob/main/modules/theme/options.nix)
.
### haus.wallpaper [#hauswallpaper]
The desktop behind everything. `minimal` is generated on this machine — a flat field at whatever depth you pick out of the palette, the haus mark ⌂ at its centre, a bloom in your accent, and enough grain that none of it bands. The other looks are the hand-made Nebelung ones.
#### `haus.wallpaper.background` [#hauswallpaperbackground]
`null or string matching the pattern #[0-9a-fA-F]{6}` · default `null`
The field colour, as a literal hex — an escape hatch out of the palette
for a desktop that wants a colour Nebelung doesn't have.
Null (the default) resolves it from haus.wallpaper.depth against the
flavour's ladder, which is the arrangement that keeps following the
theme. Setting this pins the field and `depth` stops meaning anything.
Example:
```nix
"#0b0b0e"
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.debug.enable` [#hauswallpaperdebugenable]
`boolean` · default `false`
Print this machine's lock edges in the bottom-left corner — which
revision of each family repo the running system was built from.
It is a detail rather than a readout. It sits at exactly the inset a
tiled window covers (see `debug.inset`), so it is invisible the moment
anything is on screen and only ever surfaces on a bare desktop; it is
set small, dim and wide-tracked; and it names four repos rather than
everything the flake pins. Off by default.
Example:
```nix
true
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.debug.inputs` [#hauswallpaperdebuginputs]
`list of string` · default `[ "self" "nebelung" "pounce" "perch" "holt" ]`
Which flake inputs the band names, in the order it prints them. `self`
is haus itself and prints as `haus`; every other entry is an input
name out of haus's own flake, and one that isn't there is skipped
rather than failing the build.
The default is the family chain, which is the one thing a rev is worth
knowing on a desktop: it's what `bench status` calls the lock edges, and
the answer to "is this machine running the branch I just merged".
Example:
```nix
[
"self"
"nixpkgs"
]
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.debug.inset` [#hauswallpaperdebuginset]
`null or (unsigned integer, meaning >=0)` · default `null`
How far in from the bottom-left corner the band sits, in PICTURE
PIXELS.
Null derives it from the tiling gaps — the widest outer reservation any
attached display could be using (../lib/gaps.nix, the same numbers prowl
writes into aerospace.toml), doubled for a Retina display's two pixels
per point. That lands the band exactly at a tiled window's bottom-left
corner, which is the whole trick: the text is under the windows, not
beside them, so a tiled desktop hides it completely and a bare one
doesn't.
Set a number if your display isn't 2× — or if you'd rather see it.
Example:
```nix
96
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.debug.size` [#hauswallpaperdebugsize]
`integer or floating point number between 0.002 and 0.1 (both inclusive)` · default `0.011`
The band's type size, as a fraction of the picture's short edge. It is
set in haus.fonts.mono, so the desktop and the terminal in front of it
are the same typeface.
Example:
```nix
0.02
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.depth` [#hauswallpaperdepth]
`integer between 0 and 5 (both inclusive)` · default `1`
How far in from the palette's outermost tone the field sits — the
answer to "I want it blacker" without anyone having to name a colour.
Nebelung's background tones are a ladder of six, ordered here from the
end nearest the polarity's extreme inwards, so the SAME number means the
same distance from black in a dark flavour and from white in a light one:
depth dark (mocha) light (latte)
0 crust #121212 base #f1f1f1
1 mantle #191919 mantle #e9e9e9 ← default
2 base #202020 crust #e0e0e0
3 surface0 #343434 surface0 #d0d0d0
4 surface1 #494949 surface1 #c0c0c0
5 surface2 #5c5c5c surface2 #b0b0b0
0 is as far out as the palette goes — our blackest black, our whitest
white. The default of 1 lands exactly one rung inside that extreme in
EITHER polarity, which is what keeps the desktop reading as material
rather than as a hole cut in the screen while still being properly dark
in a dark flavour — a full screen of `base` reads as a big terminal window,
not as a wall behind one.
The two columns are NOT symmetric, and the asymmetry is the palette's
rather than a choice: mocha's canvas (`base`) sits at depth 2 because
two tones are darker than it, while latte's canvas is the LIGHTEST tone
it has, so it sits at depth 0. So the ONE number moves the two flavours
in opposite directions relative to their canvas — the default puts a
dark flavour one step BELOW the colour its terminal draws on (#191919) and
a light one one step below the canvas too (#e9e9e9), which is the
agreement worth having, since a full screen of near-white is the one
field size where latte's canvas stops being comfortable. `depth = 0` is
the way to match the terminal exactly in a light flavour; `depth = 2` is
the way to match it in a dark one.
Which flavour's column applies follows haus.theme.flavor, like every
other themed surface. haus.wallpaper.background overrides the whole
thing with a literal hex.
Example:
```nix
0
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.glow.color` [#hauswallpaperglowcolor]
`null or string matching the pattern #[0-9a-fA-F]{6}` · default `null`
The colour the bloom tends towards at its centre. Null takes
haus.theme.accent's hex, which is what makes the desktop change
temperature with the accent without anyone wiring a second colour.
Example:
```nix
"#8db4f3"
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.glow.enable` [#hauswallpaperglowenable]
`boolean` · default `true`
A single broad bloom behind the mark, so the field reads as lit rather
than as a fill. Subtle by construction — see `glow.strength`.
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.glow.spread` [#hauswallpaperglowspread]
`integer or floating point number between 0.2 and 4.0 (both inclusive)` · default `1.15`
The bloom's diameter, as a multiple of the picture's long edge. Above 1
its falloff runs off the edges and the field reads as evenly lit from
the middle; below 1 it closes into a halo around the mark.
Example:
```nix
0.6
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.glow.strength` [#hauswallpaperglowstrength]
`integer between 0 and 100 (both inclusive)` · default `3`
How much of the bloom is mixed into the field, as a percentage.
Small numbers on purpose: at 3 the accent is a few levels of lift you'd
struggle to name and would miss if it went. Past \~25 it stops being
light on a wall and starts being a coloured wallpaper, which is a
different desktop than this one. (Was 7 until it turned out to read as
the field simply not being dark enough, rather than as a glow.)
Example:
```nix
14
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.grain` [#hauswallpapergrain]
`integer or floating point number between 0.0 and 0.1 (both inclusive)` · default `0.01`
Film grain over the whole field, as a fraction of full scale — and the
reason the glow doesn't band.
This is dither, dressed as texture. A soft glow across two thousand
pixels spends perhaps ten of the 256 levels an 8-bit PNG has, so it
quantises into visible contour rings — the "steppy gradient" every
hand-made wallpaper picks up on the way out of an image editor. Noise of
a couple of levels, added BEFORE the render is reduced to 8 bits, breaks
those contours into something the eye integrates back to smooth. 0.004
is enough to hide them; the default is comfortably past that.
0 turns it off. Do that only with `glow.enable = false` too — a glow on
an ungrained field is exactly the picture this exists to prevent.
Measured at the shipped defaults (3456x2234), since the effect is easier
to state in numbers than to argue about — distinct colours, and what the
PNG costs, noise being the one thing that doesn't compress:
grain colours size
0 137 0.1 MB ← rings, visibly
0.004 193 1.1 MB ← the floor worth using
0.010 329 2.5 MB ← the default
0.020 625 4.2 MB
Example:
```nix
0.0
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.mark.color` [#hauswallpapermarkcolor]
`one of "muted", "ink", "accent", "spectrum"` · default `"spectrum"`
What the mark is drawn in.
muted the palette's overlay1 — present, not loud. The mark as it
sits on hausfold.co untouched.
ink the palette's text colour, for a mark meant to be read
rather than noticed.
accent haus.theme.accent's hex, flat.
spectrum the whole family at once: a conic sweep through the six
product accents — nebelung, holt, perch, trill, pounce,
nebelhaus — clipped to the stroke. This is the ⌂ as it
looks with a pointer on it on hausfold.co, held still.
`spectrum` is the default, and follows the flavour like everything
else: the six are the Nebelung pastels in a dark flavour and their darker
counterparts in a light one, because a pastel sheen on a white wall is
invisible. It is the loudest of the four on purpose — one small piece of
colour is the whole of what this desktop says out loud, and it says the
family rather than any one product. `muted` is the quiet way back, and
`mark.opacity` turns the sweep down without leaving it.
Example:
```nix
"muted"
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.mark.enable` [#hauswallpapermarkenable]
`boolean` · default `true`
Draw the haus mark ⌂ at the centre. Off leaves the field, the glow and
the grain — which is a perfectly good desktop, and the fastest way to
get one flat colour that still isn't flat.
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.mark.opacity` [#hauswallpapermarkopacity]
`integer or floating point number between 0.0 and 1.0 (both inclusive)` · default `1.0`
The mark's opacity over the field. Worth reaching for with `spectrum`
— the default, and the one colour here loud enough to want turning down.
Example:
```nix
0.55
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.mark.rise` [#hauswallpapermarkrise]
`integer or floating point number between -0.5 and 0.5 (both inclusive)` · default `0.0`
How far above centre the mark sits, as a fraction of the picture's
height. Optical centre is a little above geometric centre, and a bar
along the top edge moves it further — a small positive number is the
usual correction.
Example:
```nix
0.06
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.mark.size` [#hauswallpapermarksize]
`integer or floating point number between 0.01 and 0.9 (both inclusive)` · default `0.1`
The mark's height, as a fraction of the picture's SHORT edge — so it
keeps its proportion whatever `size` and whatever display.
Example:
```nix
0.3
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.mark.weight` [#hauswallpapermarkweight]
`integer or floating point number between 0.005 and 0.25 (both inclusive)` · default `0.09`
Stroke width, as a fraction of the mark's own height.
The mark's SHAPE is the real U+2302, traced off the outline hausfold.co
renders, and the default weight is now the site's too: that glyph's
stems are a tenth of its height, which is 0.094 here once the miter at
the apex is counted, and 0.09 is that within a hair. So the desktop and
the site draw the same mark, which is the agreement worth having when
the two sit side by side.
It used to default to 0.055 — a little under 60% of the glyph's own
weight — on the grounds that a stem which reads right in a line of type
is heavy drawn a foot wide on a wall. That is true of a mark filling the
screen; it is not true of one at `mark.size`, where the lighter stroke
reads as a hairline rather than as the ⌂. Go back to it if you want the
outline to recede.
Example:
```nix
0.055
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.size` [#hauswallpapersize]
`string matching the pattern [0-9]+x[0-9]+` · default `"3456x2234"`
The pixel size `minimal` is rendered at, `WIDTHxHEIGHT`.
Set it to your display's NATIVE pixel count and macOS has nothing left
to do: the picture lands one image pixel per screen pixel, which is the
only arrangement where the grain that keeps the glow smooth survives at
the size it was dithered for. Anything else is resampled, and resampling
is where a gradient that was clean in the file starts to look stepped.
The default is the 16" MacBook Pro panel — the largest built-in Retina
display, so a smaller one scales DOWN (soft, harmless) rather than up.
`system_profiler SPDisplaysDataType` prints yours.
Aspect matters as much as size: macOS fills the screen and crops the
overflow, so a picture narrower than the display loses its top and
bottom — which is where `debug` draws. On a display of a different
shape, set this to that display's own numbers.
Example:
```nix
"3024x1964"
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
#### `haus.wallpaper.style` [#hauswallpaperstyle]
`one of "none", "minimal", "orbits", "constellation", "flow", "bold"` · default `"none"`
Which desktop this machine wears, set at each home-manager activation
(osascript, every desktop on the current Space).
minimal GENERATED here — a flat field in your palette, the
haus mark ⌂ at its centre, and nothing else. The one
haus-themed look, and the one every option below tunes.
orbits hand-made Nebelung PNGs, the palette baked into their
constellation pixels — they do not follow haus.theme.flavor.
flow
bold generated from haus.theme.accent alone (a diagonal
accent→crust sweep), which predates `minimal`.
The default is `none`, and it is a real value rather than an absence:
nothing here runs and whatever wallpaper you already have stays exactly
where it was. Replacing someone's desktop picture is the most visible
thing this layer can do, so the bare room does not do it uninvited — a
DESKTOP says which look it wants, and nebelhaus picks `minimal`.
That is the second change of mind on this option, and the reasoning
survives both: `minimal` was made the default so a desktop wouldn't ship
looking like nothing in particular, which is still true of nebelhaus
and is why its desktop sets it. What changed is that a desktop is now
the thing making that choice, rather than every install of the layer.
Every option below tunes `minimal`, and all of them keep their tuned
values — the choice being opt-in is not a reason for the look to be
worse once chosen.
Example:
```nix
"minimal"
```
Declared in
[`modules/wallpaper/options.nix`](https://github.com/hausfold/haus/blob/main/modules/wallpaper/options.nix)
.
### haus.fonts [#hausfonts]
The terminal font. The bar keeps its own font at its own tuned sizes.
#### `haus.fonts.mono.baseSize` [#hausfontsmonobasesize]
`positive integer, meaning >0` · default `13`
The terminal-font baseline, before `haus.ui.scale` multiplies it.
The neutral room uses 13pt; nebelhaus selects 19pt in its desktop.
This exists so a desktop can carry that tuned baseline WITHOUT
breaking the scale relationship. Setting `size` directly pins an
absolute number, which would make `haus.ui.scale` (and
`haus.appearance.largePrint`, built on it) stop moving the terminal font at
all — a silent regression, since everything else would still grow.
Say the baseline here; say the exception with `size`.
Example:
```nix
19
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.fonts.mono.name` [#hausfontsmononame]
`string` · default `"JetBrainsMono Nerd Font Mono"`
haus's type family, as Ghostty's `font-family` names it.
It reaches the terminal AND the menu bar: every pill label and icon
sill draws is in this family, at sizes of its own (see
`haus.ui.scale`). The workspace-logo glyphs are the one exception
— those are sketchybar-app-font, which sill installs itself.
This should be a NERD FONT patched build: starship's prompt, lsd's
icons, yazi previews and half the bar's icons draw with glyphs a stock
font renders as tofu. If you change this, set `package` (or
`packageName`) too — haus can only install a font it's been given,
and it warns when you name a family without one.
The name is taken verbatim, so a "… Nerd Font Mono" family is drawn in
the bar as well: the bar mixes icon glyphs into its labels, which is
the same reason the terminal wants a patched font.
Example:
```nix
"Berkeley Mono"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.fonts.mono.package` [#hausfontsmonopackage]
`null or package` · default `null`
The package providing `name`. null (the default) installs haus's
own JetBrains Mono Nerd Font, which is what `name` defaults to.
Set this whenever you change `name`, or the family simply won't exist
on the machine and Ghostty will silently fall back — haus warns if
it spots that combination.
A shared desktop can't set this one — it needs `pkgs`, and a data-only
desktop has no arguments. Use `packageName` there.
Example:
```nix
pkgs.nerd-fonts.fira-code
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.fonts.mono.packageName` [#hausfontsmonopackagename]
`null or string` · default `null`
The same thing as `package`, NAMED rather than evaluated: an
attribute path into nixpkgs, so "nerd-fonts.fira-code" means
`pkgs.nerd-fonts.fira-code`.
This exists so a data-only desktop or app pack can change the font
FAMILY and not just its size — reaching `pkgs` is precisely what those
formats forbid, which made `fonts.mono.package` unreachable to every
shared file. A name is data; a package is code.
Set one or the other, never both. A name that resolves to nothing, or
to a set of packages rather than a package, fails at eval with the
spelling to try instead.
Example:
```nix
"nerd-fonts.fira-code"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.fonts.mono.size` [#hausfontsmonosize]
`positive integer, meaning >0` · default `fonts.mono.baseSize, scaled by haus.ui.scale and rounded`
Terminal font size in points. The single most useful knob for a
larger-text machine, since it moves everything haus actually
lives in.
nebelhaus's 19pt baseline exists for a reason worth knowing: the Ghostty window is
tiled to a fixed pixel height by prowl, and sizes that don't divide
that height evenly used to leave a gap under zellij's status bar.
That's since been fixed properly (window-padding-balance +
`extend-always`), so any size is safe now — 19 is simply the tuned
starting point.
Example:
```nix
24
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.accessibility [#hausaccessibility]
macOS accessibility keys haus can actually apply. These write to a TCC-protected domain, so they take effect only when the app you run the rebuild from holds Full Disk Access — otherwise haus warns and moves on.
#### `haus.accessibility.closeViewScrollWheelToggle` [#hausaccessibilitycloseviewscrollwheeltoggle]
`null or boolean` · default `null`
Hold ⌃ (Control) and scroll to magnify the whole display.
macOS calls it "Use scroll gesture with modifier keys to zoom";
scroll the other way to come back.
The fastest zoom on the Mac and the one people forget exists. Worth
having on a machine you demo, present or pair from, where the
alternative is asking everyone to lean in.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
HOW THIS ONE WAS VERIFIED — by a person looking at the screen on
real hardware (macOS 26.6.1, 2026-08-14), because no API reports
it: `NSWorkspace` exposes no pointer size and no zoom state, so
`hausax` cannot read it back and `haus diff` will tell you it
compared the plist and nothing more. haus's other
accessibility options are checked against macOS's own answer on
YOUR Mac; this one carries evidence from one Mac. That is a real
difference and it is worth knowing which kind you are getting —
it is written here rather than in a changelog because the option
is where you will be standing when it matters.
NEEDS A DAEMON RESTART, which the rebuild does for you: the write
alone changes nothing on screen until `universalaccessd`
restarts, which is exactly why this key spent three weeks
looking dead. `haus rebuild` kills it whenever this option
family is set, so you should never meet the stale state; set the
key by hand with `defaults write` and you will, with
`killall universalaccessd` as the fix.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.accessibility.closeViewZoomFollowsFocus` [#hausaccessibilitycloseviewzoomfollowsfocus]
`null or boolean` · default `null`
Keep a zoomed display on whatever has keyboard focus.
⇥ into a field outside the magnified area and the view goes to it —
KEYBOARD focus specifically, not the pointer.
Needs a zoom to follow, so it does nothing on its own: pair it with
`closeViewScrollWheelToggle` (or turn on Zoom in System Settings ▸
Accessibility). nix-darwin's own option says the same, and it is the
first thing to check if this appears to do nothing.
Expect it to SNAP rather than glide — the view jumps to the focused
control in one step. That is the feature behaving, not a rendering
fault, and it is the first thing anyone reports as one. Note also
that pushing the POINTER at a screen edge pans the zoomed view
whether or not this is set: that behaviour is not this option, which
matters if you are trying to tell whether it took.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
HOW THIS ONE WAS VERIFIED — by a person looking at the screen on
real hardware (macOS 26.6.1, 2026-08-14), because no API reports
it: `NSWorkspace` exposes no pointer size and no zoom state, so
`hausax` cannot read it back and `haus diff` will tell you it
compared the plist and nothing more. haus's other
accessibility options are checked against macOS's own answer on
YOUR Mac; this one carries evidence from one Mac. That is a real
difference and it is worth knowing which kind you are getting —
it is written here rather than in a changelog because the option
is where you will be standing when it matters.
NEEDS A DAEMON RESTART, which the rebuild does for you: the write
alone changes nothing on screen until `universalaccessd`
restarts, which is exactly why this key spent three weeks
looking dead. `haus rebuild` kills it whenever this option
family is set, so you should never meet the stale state; set the
key by hand with `defaults write` and you will, with
`killall universalaccessd` as the fix.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.accessibility.differentiateWithoutColor` [#hausaccessibilitydifferentiatewithoutcolor]
`null or boolean` · default `null`
macOS's "Differentiate without colour" — native UI adds shapes and
text where it would otherwise rely on hue alone. The setting to pair
with a desktop built for colour-blind readability.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.accessibility.increaseContrast` [#hausaccessibilityincreasecontrast]
`null or boolean` · default `null`
macOS's "Increase contrast" — stronger borders and reduced use of
colour alone to convey state, across native apps. This is the
system-level companion to a high-contrast nebelhaus theme: the theme
restyles the tools nebelhaus colours, this reaches everything else.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.accessibility.mouseDriverCursorSize` [#hausaccessibilitymousedrivercursorsize]
`null or integer or floating point number between 1.0 and 4.0 (both inclusive)` · default `null`
How big the mouse pointer is — 1.0 normal, 4.0 the biggest.
That range is macOS's own, and the scale is linear: 2.0 is twice
the normal pointer, 4.0 is the largest System Settings offers.
The one accessibility key here that is useful with no accessibility
need at all: on a 5K display, or from across a desk, or in a
screen recording someone else has to follow, the default pointer is
simply too small to find. Set it to 1.5 and you keep noticing you
can see it.
Deliberately NOT wired to `haus.ui.scale`, which would be the
obvious thing and is the wrong thing. `ui.scale` is the foundation
every machine gets; this domain needs Full Disk Access. Deriving
one from the other would make `ui.scale = 1.4` start warning about
a TCC grant on machines that never asked for a bigger pointer, for
a write that would then be skipped. Reach for this key when you
want it — it sharpens a large-text desktop, it does not underpin
one.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
HOW THIS ONE WAS VERIFIED — by a person looking at the screen on
real hardware (macOS 26.6.1, 2026-08-14), because no API reports
it: `NSWorkspace` exposes no pointer size and no zoom state, so
`hausax` cannot read it back and `haus diff` will tell you it
compared the plist and nothing more. haus's other
accessibility options are checked against macOS's own answer on
YOUR Mac; this one carries evidence from one Mac. That is a real
difference and it is worth knowing which kind you are getting —
it is written here rather than in a changelog because the option
is where you will be standing when it matters.
NEEDS A DAEMON RESTART, which the rebuild does for you: the write
alone changes nothing on screen until `universalaccessd`
restarts, which is exactly why this key spent three weeks
looking dead. `haus rebuild` kills it whenever this option
family is set, so you should never meet the stale state; set the
key by hand with `defaults write` and you will, with
`killall universalaccessd` as the fix.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
4.0
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.accessibility.reduceMotion` [#hausaccessibilityreducemotion]
`null or boolean` · default `null`
macOS's "Reduce motion" — Spaces cross-fade instead of sliding,
Mission Control and the Dock drop their zoom animations, and window
minimise/restore is stripped back.
BIGGER THAN IT LOOKS, which is why it is here rather than inside
`haus.animations`. This is the single flag every browser maps to the
`prefers-reduced-motion: reduce` CSS media query, through
`NSWorkspace.accessibilityDisplayShouldReduceMotion` — `hausax` reads
that exact property, so `hausax | jq .reduceMotion` is how you check
it landed. Turning it on rewrites the web: mostly for the better,
except on sites whose scroll-reveal animation is what makes the
content visible in the first place, which then never appears at all.
If what you want is a snappier Dock and nothing else,
`haus.animations = "fast"` is five plain timing keys in two ordinary
domains, moves no accessibility flag, and needs no TCC grant.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.accessibility.reduceTransparency` [#hausaccessibilityreducetransparency]
`null or boolean` · default `null`
macOS's "Reduce transparency" — the menu bar, Control Center, sheets
and sidebars stop sampling what is behind them and go opaque. The
setting to reach for if Liquid Glass costs you more legibility than
it buys, and it pairs naturally with `increaseContrast`.
Worth knowing if you run the bar: sill paints its own background, so
its pills look the same either way — what changes is the macOS menu
bar band behind them, which stops being translucent.
null (the default) leaves whatever you have alone — this is a
personal setting, so haus never picks a value for you.
REACHABILITY — `com.apple.universalaccess` is TCC-protected, and this is
the one property in the whole option surface that can differ
between two machines running byte-identical config, so it is worth
reading once. The write lands only when the app that runs the
rebuild holds Full Disk Access (System Settings ▸ Privacy &
Security ▸ Full Disk Access; on macOS 26 a stale grant often needs
removing and re-adding with (+), then restarting the terminal).
The grant follows the APP, not you and not root — so an
agent-driven rebuild in a pane of a terminal that has it works
fine, and the same agent under a different app does not.
Without the grant haus warns and carries on: you lose this
setting and nothing else. That containment is the whole reason
`com.apple.universalaccess` has options here at all — written the
other way, through `system.defaults.universalaccess.*`, a missing
grant aborts the rest of activation and takes every background
service haus installs with it.
`haus plan` says up front when a rebuild needs the grant, and
`haus doctor`'s Permissions section says whether this app has it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.animations [#hausanimations]
How much motion macOS spends on its own Dock and windows: the slide, the launch bounce, minimise, Mission Control, window open/close. Unset by default like the rest of this block — `"fast"` opts in, and going back only stops writing rather than restoring. Deliberately not the Accessibility "Reduce motion" switch, which every browser also reads as `prefers-reduced-motion`.
#### `haus.animations` [#hausanimations-1]
`one of "fast", "system"` · default `"system"`
How much motion macOS spends on its own Dock and windows — how long
three animations run, and two it plays at all.
`"system"` (the default) writes NOTHING — not the macOS values, nothing
at all — so whatever your Dock does today, it keeps doing. Same policy
as `haus.hotCorners`: haus doesn't overwrite a setting you didn't
ask it about.
`"fast"` writes five keys, all `mkDefault`, so any one of them can be
overridden by name in your host file:
```
com.apple.dock autohide-time-modifier 0.15 Dock slide
com.apple.dock expose-animation-duration 0.1 Mission Control
com.apple.dock launchanim false the bouncing icon
com.apple.dock mineffect scale minimise (not genie)
NSGlobalDomain NSAutomaticWindowAnimationsEnabled false window open/close
```
GOING BACK IS NOT AUTOMATIC, which is the one thing about this group
that can surprise you and the reason it isn't on by default. Setting
`"system"` again means STOP WRITING, not RESTORE: a `defaults` write is
sticky and macOS keeps no memory of what was there before, so once
you've rebuilt on `"fast"`, the five keys keep haus's numbers.
Undoing it means naming the values you want back in your host file
(they're `mkDefault`, so a plain value wins), or a `defaults delete`.
Worth knowing before you try `"fast"` on a Dock you tuned by hand.
WHY THIS ISN'T "REDUCE MOTION". macOS's accessibility switch of that
name (`com.apple.universalaccess reduceMotion`) would cover all of this
and more — but it is also the single flag every browser maps to the
`prefers-reduced-motion: reduce` CSS media query, via
`NSWorkspace.accessibilityDisplayShouldReduceMotion`. Turning it on
rewrites the web: mostly for the better, except on sites whose
scroll-reveal animation is what sets the content visible in the first
place, which then never appears at all. These five keys are in two
entirely different domains and move no accessibility flag — `hausax`
reads that exact `NSWorkspace` property, so `hausax | jq .reduceMotion`
stays `false` with this set to `"fast"` (on a machine that hasn't also
set `haus.accessibility.reduceMotion`, which is the option that DOES move
it) — that's the whole reason this
group exists as five curated keys instead of one switch. If you DO want
the accessibility switch, it is `haus.accessibility.reduceMotion`: a
separate option, in a TCC-protected domain, with that blast radius spelled
out on it. Setting both is coherent and neither implies the other.
WHEN YOU'LL FEEL IT. The four Dock keys are live the moment activation
finishes — nix-darwin restarts the Dock whenever anything in its domain
is written, and haus always writes `autohide`. The NSGlobalDomain
one is read by each app AT LAUNCH, so apps you already have open keep
animating their windows until you relaunch them; `activateSettings`
can't reach back into a running `NSApplication`.
These are timings, not a state haus can prove from a plist — unlike
the `haus.accessibility` keys, there's no oracle for "did the Dock
slide faster". They're felt, not measured. The one measurable claim
here is the negative one above.
Example:
```nix
"fast"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.screenshots [#hausscreenshots]
Where ⇧⌘4 puts its files, in what format, and whether it draws a window shadow or a preview thumbnail. Unset by default, so macOS's own choices stand.
#### `haus.screenshots.format` [#hausscreenshotsformat]
`null or one of "png", "jpg", "pdf", "tiff", "heic", "gif"` · default `null`
The image format new screenshots are saved in. null (the default)
leaves macOS's own choice alone, which is png.
png is lossless and the right default for UI and text — a jpg
screenshot of a terminal has visible ringing around every glyph. jpg
is worth choosing only when you screenshot photographs often enough
for the file sizes to matter.
Example:
```nix
"png"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.screenshots.includeDate` [#hausscreenshotsincludedate]
`null or boolean` · default `null`
Whether filenames carry the date and time ("Screenshot 2026-08-03 at
13.37.20.png") or just a counter ("Screenshot 1.png"). null (the
default) leaves macOS's own choice alone, which is to include it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.screenshots.location` [#hausscreenshotslocation]
`null or string` · default `null`
Where ⇧⌘3 / ⇧⌘4 / ⇧⌘5 write their files. null (the default) leaves
macOS's own choice alone, which is the Desktop.
Absolute, or starting with `~/` — haus expands the `~` for you and
CREATES the directory during activation. Both halves matter: macOS
stores this string verbatim and expands nothing, and if the path does
not exist screencapture silently falls back to the Desktop, so a
typo'd or not-yet-created folder looks exactly like the setting having
been ignored.
Example:
```nix
"~/Pictures/Screenshots"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.screenshots.shadow` [#hausscreenshotsshadow]
`null or boolean` · default `null`
Whether a window capture (⇧⌘4 then Space) keeps macOS's big soft drop
shadow. null (the default) leaves macOS's own choice alone, which is
to include it.
false is the setting to want if screenshots go into documentation: the
shadow is transparent padding, so it adds a wide invisible margin that
every layout then has to fight. Holding ⌥ while you click suppresses
it for one capture either way.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.screenshots.thumbnail` [#hausscreenshotsthumbnail]
`null or boolean` · default `null`
Whether the floating preview thumbnail appears in the bottom-right
corner after a capture. null (the default) leaves macOS's own choice
alone, which is to show it.
false writes the file immediately instead of after the \~5s the
thumbnail waits around — the setting to want if you screenshot in
quick succession, or if you script anything that reads the file. The
cost is losing the markup/drag affordance the thumbnail offers.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.sound [#haussound]
Alert volume and sound, interface sound effects, and the boot chime. Volume is 0–100 the way the slider reads it — macOS stores a curve, and haus does the conversion.
#### `haus.sound.alertSound` [#haussoundalertsound]
`null or one of "Basso", "Blow", "Bottle", "Frog", "Funk", "Glass", "Hero", "Morse", "Ping", "Pop", "Purr", "Sosumi", "Submarine", "Tink"` · default `null`
Which sound the alert beep plays, by name:
```
Basso Blow Bottle Frog Funk Glass Hero Morse Ping Pop Purr Sosumi Submarine Tink
```
null (the default) leaves macOS's own choice alone.
An enum rather than a path on purpose. macOS stores an absolute path
here and validates nothing, and a path that doesn't resolve does not
fall back to the default beep — it goes SILENT (measured by ear,
2026-08-08), while the plist still reads like a working setting.
haus builds the path from the name and skips the write with a warning
if that file is missing, so a macOS release retiring a sound can't
quietly mute you.
Example:
```nix
"Submarine"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.sound.alertVolume` [#haussoundalertvolume]
`null or integer between 0 and 100 (both inclusive)` · default `null`
How loud the alert beep is, 0–100, exactly as the slider in System
Settings ▸ Sound reads. null (the default) leaves macOS's own choice
alone.
haus converts to the exponential value macOS actually stores
(`e^(v/100 − 1)`, with 0 meaning silence), because that key is not a
fraction: writing the obvious `0.5` gets you 31%.
TWO WRITERS: the volume keys and the Sound pane write this same key.
Declaring it means every rebuild reasserts your number over anything
you changed by hand since — which is the point of declaring it, but
leave it null if you'd rather the slider win.
Example:
```nix
50
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.sound.startupChime` [#haussoundstartupchime]
`null or boolean` · default `null`
The chime a Mac plays at boot. null (the default) leaves it alone.
The odd one in this group: it is firmware state (`nvram StartupMute`),
not a preference, so it survives an OS reinstall and a wiped home
directory — and it is the only setting here that needs the rebuild to
run as root, which activation already does.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.sound.uiSounds` [#haussounduisounds]
`null or boolean` · default `null`
Play user-interface sound effects — the Trash whoosh, the screenshot
shutter, the Mail whoosh. null (the default) leaves macOS's own
choice alone.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.sound.volumeFeedback` [#haussoundvolumefeedback]
`null or boolean` · default `null`
Play a sound when the volume keys change the volume. null (the
default) leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
## Displays [#displays]
Resolution and per-display behaviour, addressed by which screen you mean rather than by a panel's serial number.
### haus.displays [#hausdisplays]
Per-display overrides, keyed by which screen you mean.
#### `haus.displays` [#hausdisplays-1]
`attribute set of (submodule)` · default `{ }`
Per-display settings, keyed by which screen you mean:
internal the built-in panel
main whichever display is currently main
\ a persistent display UUID, for a specific external monitor —
run `hausdisp list` to print the UUIDs of what's attached
Default is the empty set, and then nothing about your displays is touched.
A key naming a display that isn't plugged in right now is skipped with a
note, not an error, so a `displays.` entry for the monitor at the
office can't fail a rebuild on the train.
Why this option exists at all: display scaling is the only lever macOS 26
gives us for "make EVERYTHING bigger", system-wide, including apps haus
knows nothing about. macOS's own text-size setting writes a value no running
app re-reads, while the accessibility scalars that do work affect contrast
or motion rather than system-wide size — measured, not assumed (the
workshop's notes/macos-settings-matrix.md records the sweep). So
`haus.ui.scale` and `haus.fonts` make *haus's own tools* bigger, and
this makes the *Mac* bigger.
Example:
```nix
{
"37D8832A-2D66-02CA-B9F7-8F30A301B230" = {
uiScale = "more-space";
};
internal = {
uiScale = "larger-text";
};
}
```
Declared in
[`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)
.
#### `haus.displays..uiScale` [#hausdisplaysnameuiscale]
`null or one of "more-space", "default", "larger-text", "largest-text"` · default `null`
The scaled resolution, as an intent rather than a pixel count — the
same four positions System Settings ▸ Displays offers, named:
more-space the largest resolution the panel offers (smallest UI)
default the panel's own default mode
larger-text between the default and the smallest resolution
largest-text the smallest resolution the panel offers (biggest UI)
Resolved per panel from the modes that panel actually reports, so the
same value means the same *thing* on a 14" laptop and a 27" monitor
rather than the same number of pixels. On the 14" MacBook Pro this was
developed on that resolves to 1800x1169 · 1512x982 · 1147x745 ·
1024x665.
Applied at each home-manager activation and set permanently, so it
survives a reboot; re-applying an already-current mode is a no-op, so
a rebuild doesn't flash your screen. null (the default) leaves the
display alone.
When more than one selector names the same attached panel, the more
specific setting wins: UUID over internal over main. This lets a
host-specific display setting refine a broad profile such as
`haus.appearance.largePrint` without depending on activation order.
Honest scope: this is a real, system-wide size change — every app gets
bigger, not just haus's own tools — and the cost is desk space,
because a larger UI means less of it. It also can't run from a rebuild
with no GUI session attached (over SSH, say); the setting applies at
the next activation you run while logged in.
Example:
```nix
"larger-text"
```
Declared in
[`modules/displays/options.nix`](https://github.com/hausfold/haus/blob/main/modules/displays/options.nix)
.
## Development [#development]
The terminal stack — terminal, shell, multiplexer, editor — plus the browser, the CLI toolbelt, Git tooling and language runtimes. Your commit identity itself is a fact about you rather than this room's, and stays in your host. The terminal lives here because a terminal with no tools in it is not a separate thing anyone wants.
### haus.hearth [#haushearth]
The shell and terminal experience.
#### `haus.hearth.editor` [#hausheartheditor]
`string` · default `the command for haus.hearth.editorName — hx for helix`
The ONE editor command haus uses everywhere. It's the shell command
for $EDITOR / $VISUAL (git, etc.) AND what every "open in an editor"
action launches — the "Nix Config" palette command, the bar's nix-open
item, and the file-association hijack. Those open the target in a new
zellij tab running this command, so a terminal editor is the natural
fit for haus; a GUI editor's CLI works too (e.g. "code" or
"code -w" to block).
It defaults to the command for `haus.hearth.editorName`, so choosing an
editor there is enough. Set this only for the case that option cannot
express: pointing haus at something it does not install. Naming a
command here does NOT install it — that machine has to already have it.
Example:
```nix
"code -w"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.editorName` [#hausheartheditorname]
`one of "helix", "nano", "neovim", "vim"` · default `"helix"`
Which editor this room installs. `helix` (the default) is the one haus
is themed around; `neovim`, `vim` and `nano` are installed as-is,
with no Nebelung theme — Nebelung has a port for helix and not for
them.
Setting this also moves `haus.hearth.editor`, since that defaults to
whatever the chosen editor answers to on PATH (`hx`, `nvim`, `vim`,
`nano`). Choosing here is the whole gesture: the editor is installed
AND every "open in an editor" action follows it.
A desktop may set this. To point haus at an editor it does not
install — a GUI one, or something from your own host file — leave this
alone and set `haus.hearth.editor` instead.
Example:
```nix
"neovim"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.floatBorder` [#haushearthfloatborder]
`one of "accent", "grey", "off", "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `"accent"`
The outline drawn around every floating terminal `float-term.sh` spawns:
the Super-y yazi peek panel, the bar's agent peek, and the palette's
Rebuild System / Install App / Settings and `zscratch` windows. They all
land on top of a tiled desktop, where a dark terminal over a dark window
behind it has no edge at all.
* `accent` (the default) — `haus.theme.accent`, so a summoned window
announces itself and the whole desktop keeps one accent.
* `grey` — Nebelung's `surface0`, one step off the terminal's own
background: the same relationship the bar's dropdowns wear
(`popup.background.border_color` in modules/sill), for an edge that
defines the window without drawing the eye.
* `off` — no outline; the look before this option existed. It also keeps
floatring out of the closure entirely, so nothing is compiled for it.
* any Nebelung accent name (`lavender`, `sapphire`, …) — one colour for
these popups that ISN'T `haus.theme.accent`, the same escape hatch
`haus.sill.logo.color` offers.
2pt, following the window's own corner curve. Drawn by a tiny overlay
window (modules/hearth/floatring.swift) that lives and dies with the
popup, because Ghostty has no border setting of its own and aerospace
draws none — that file's header has the rest, including why it isn't
JankyBorders. Switch it with
`haus set hearth.floatBorder grey && haus rebuild`; to compare colours
first, without a rebuild, outline any window by hand (the process name is
lower-case — `pgrep -x Ghostty` matches nothing and rings nothing):
`~/.config/zellij/float-term.sh ring "$(pgrep -x ghostty | head -1)" '#cba6f7'`
Example:
```nix
"grey"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.ghDash.enable` [#haushearthghdashenable]
`boolean` · default `false`
Whether to enable the themed gh-dash GitHub dashboard and its Cmd-G
fullscreen Zellij overlay.
Enabling it gets you the issue and notification tabs (yours, assigned,
unread, participating). The four PR tabs — open / green / red /
shipped — need `haus.git.org` as well, since a PR section is a search
filter scoped to an owner. A host can compose or replace any of it
through home-manager's `programs.gh-dash.settings`: every section list
Hearth writes is a `mkDefault`, per list.
Needs `haus.developer.git.enable` (an assertion enforces it): gh-dash
authenticates out of `gh`'s own credentials, so the Git pack is where
its login comes from.
Example:
```nix
true
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.hijackFileAssociations` [#haushearthhijackfileassociations]
`boolean` · default `false`
When true, build a small opener app and make it the default handler
for \~80 text/code extensions (json, md, ts, nix, rs, go, kdl, …), so
opening or clicking those files opens them in haus.hearth.editor in
a terminal tab. The app declares the types itself (not just `duti`) so
extensions nothing else on the machine declares still bind. Off by
default: silently rewriting your file associations is a jarring,
hard-to-undo change, so it's strictly opt-in. (Extensionless executables
like `bench` are NOT covered — macOS gates the public.unix-executable
handler behind an interactive dialog; set it by hand once if wanted:
`duti -s org.nebelhaus.editoropen public.unix-executable all`.)
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.lanes.backend` [#haushearthlanesbackend]
`one of "zellij", "zmx"` · default `"zellij"`
Where an agent lane's terminal actually lives.
`zellij` (the default) is the behaviour haus has always had: a
lane is a pane in the `main` zellij session, and `holt` execs the
client in the pane you ran it from. Panes are cheap, but a lane's
identity is then a (session, pane-id) pair that only zellij
understands — which is why the bar keeps a state file per pane and
joins it back to a checkout path to work out which window to raise.
`zmx` makes the lane its own zmx session, viewed through its own
Ghostty window, tiled by prowl — all three named
`holt..`. Three consequences, in the order you'd feel
them:
* **Closing the window stops meaning parking the work.** A zmx
session outlives every client attached to it, so ⌘W detaches and
the agent keeps thinking; `holt ` reopens a window onto the
live conversation instead of resuming a transcript.
* **The name is the join.** `zmx ls --where state=waiting`,
AeroSpace's `window-title-regex`, and the lane in `holt --json`
all key off one string.
* **Splits are gone**, because zmx has none by design. Prowl tiles
the windows instead, which is the trade: a real window manager
rather than a second one nested inside a terminal.
Both backends can be installed at once — this only picks which one
`holt` opens into, through the `[hooks] open`/`resume` seam in
`~/.config/holt/config.toml`. If zmx is somehow missing at runtime
the hook defers (exit 3) and holt falls back to its built-in, so the
worst case is the zellij behaviour you already had.
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.obsidianVaults` [#haushearthobsidianvaults]
`list of string` · default `[ ]`
Home-relative paths to existing Obsidian vaults that should use the
Nebelung theme. On each activation, Hearth copies the rendered
theme.css + manifest.json into each vault's .obsidian/themes/Nebelung/
directory, selects Nebelung's dark appearance in appearance.json, and
removes the obsolete "nebelung" CSS snippet from the enabled list.
Empty (the default) leaves every vault untouched. Paths must be
relative to the user's home, may not contain "..", and are skipped
with a warning unless their .obsidian directory already exists.
Example:
```nix
[
"Library/Mobile Documents/iCloud~md~obsidian/Documents/notes"
]
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.rightClickFullscreen` [#haushearthrightclickfullscreen]
`boolean` · default `true`
When true (the default), a bare right-click on any pane zooms it
fullscreen — the same MouseAction::ToggleFullscreen Ctrl+Click already
triggers, just a different, easier-to-reach trigger. It's a whole
zellij-unwrapped patch, not a config toggle (mouse buttons still
aren't bindable in config.kdl — see naked-click-links.patch's header
for why link gestures hit the same wall), so flipping this rebuilds
zellij; it does not take effect on a running server. The real cost:
right-click stops reaching the pane's own program, so a TUI's own
right-click context menu (lazygit, vim, mc, …) goes with it. Set
false to leave right-click alone and keep zooming with Ctrl+Click or
Super Enter.
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.hearth.zellijStartLocked` [#haushearthzellijstartlocked]
`boolean` · default `true`
When true (the default), zellij boots into Locked input mode instead of
Normal — its single-key submode leaders (pane, tab, resize, …) stay
inert until you unlock with Ctrl-g, so a stray keystroke can't jump you
into a submode. The `Super`-prefixed launchers (claude / pane / tab /
yazi-peek / fullscreen) are bound in `shared` and keep working while
locked, as do `Alt [` / `Alt ]` (cycle swap layouts) — the rest of
zellij's `Alt` row stays inert while locked, since those keys are
readline/vim word motions the pane's app wants. The bar's bottom-right
quick-hint block only shows in Locked mode. Set false to start in Normal
mode (zellij's own default).
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
### haus.zen [#hauszen]
Zen browser policy, extensions and the optional native tab bridge.
#### `haus.zen.extensions` [#hauszenextensions]
`attribute set of (submodule)` · default `{ }`
Browser extensions to deploy into Zen, by a stable id of your choosing.
The mechanism is Firefox's enterprise policies — haus renders an
`ExtensionSettings` block — so it reaches Zen the way an IT department
reaches Firefox, without a profile to hand-edit. `haus.roster`
deliberately cannot do this: a roster entry installs from a cask, a
brew, a nixpkgs package or the App Store, and a browser add-on is none
of those.
Two consequences of HOW the policies are delivered, both visible.
Firefox only ever looks for a `policies.json` inside the app bundle,
which haus has no business writing into (it breaks the code signature
and a cask upgrade wipes it), so haus uses the other route macOS
offers: a managed preference at
`/Library/Preferences/app.zen-browser.zen.plist`. That file is
root-owned, so it's written during system activation and a `haus
rebuild` that can't reach it warns instead of installing anything. And
because enterprise policies are on, Zen will tell you it is "managed by
your organization" — that organization is haus.
haus knows the id and slug of the extensions it themes
(stylus),
so those need only be named. Everything else needs `id` — see that
option for where to find it.
Naming `stylus` here also turns on the stamped userstyle bundle (see
haus.theme.accent): the Catppuccin-derived styles Stylus imports
carry their own accent and flavor variables, which no palette file can
reach, so haus stamps the bundle from your theme — accent, flavor,
and the contrast it's rendered for — and tells you when there's a new
one to import.
Example:
```nix
{
# Known to haus — id and slug are filled in.
stylus = { };
# Anything else: bring the id.
ublock-origin = {
id = "uBlock0@raymondhill.net";
slug = "ublock-origin";
};
}
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.extensions..enable` [#hauszenextensionsnameenable]
`boolean` · default `true`
Whether to deploy this extension. Set false to remove one an imported desktop added.
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.extensions..id` [#hauszenextensionsnameid]
`null or string` · default `null`
The extension's own id — the key Firefox's policy engine
matches on, NOT its AMO slug. Usually a brace-wrapped UUID,
sometimes an email-shaped string (`addon@example.org`).
Find it by installing the add-on once and reading `Extension
ID` under about:debugging ▸ This Firefox, or from the
`browser_specific_settings` block of its source. Wrong id and
the policy silently installs nothing — which is why this has
no guessable default.
Example:
```nix
"{7a7a4a92-a2a0-41d1-9fd7-1e92480d612d}"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.extensions..mode` [#hauszenextensionsnamemode]
`one of "force_installed", "normal_installed", "allowed", "blocked"` · default `"force_installed"`
Firefox's `installation_mode`. `force_installed` installs it
and stops the user removing it (the point, for a desktop that
wants an extension present); `normal_installed` installs it
but leaves it removable.
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.extensions..slug` [#hauszenextensionsnameslug]
`null or string` · default `null`
The add-on's AMO slug — the last path segment of its
addons.mozilla.org URL. Only used to build the default
`url`; set `url` directly and this is ignored.
Example:
```nix
"styl-us"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.extensions..url` [#hauszenextensionsnameurl]
`string` · default `""`
Where the .xpi comes from. Defaults to AMO's "latest" endpoint
for `slug`, so the add-on updates itself; point it at a pinned
version or a self-hosted file to freeze it.
A `file://` url has a second effect, and it is not local to
this extension: a file on disk cannot have been signed by
Mozilla, and Zen refuses an unsigned add-on
(`ERROR_SIGNEDSTATE_REQUIRED`) unless
`xpinstall.signatures.required` is off. So naming one makes
haus lock that pref off **for the whole browser** — the
same switch `haus.zen.tabBridge.enable` documents, since the
bridge is haus's own `file://` install. An `https://` AMO
url never turns it on.
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.extraPolicies` [#hauszenextrapolicies]
`attribute set` · default `{ }`
Anything else to put in Zen's policy set, merged beside the
`ExtensionSettings` block `haus.zen.extensions` renders. haus OWNS
the file these land in — `/Library/Preferences/app.zen-browser.zen.plist`,
written as root — so this is the escape hatch for the rest of the policy
surface rather than a reason to take the file back by hand. Keys here
win over haus's on a collision.
Write the policy names as Firefox documents them, nested: this becomes
the top level of a plist beside `EnterprisePoliciesEnabled`, so
`{ Extensions.Install = [ "…" ]; }` is an `Extensions` dict with an
`Install` array in it, not a key called `Extensions.Install`. Setting
every policy back to `{ }` (and naming no extensions) takes the file
down again on the next rebuild.
The merge is one level deep, so naming a policy takes that policy over
WHOLE. Two of them haus writes itself: `ExtensionSettings` (from
`haus.zen.extensions`) and `Preferences` (which is where the signature
switch a `file://` install needs ends up). Restate what you still want
if you set either — dropping the signature switch this way is invisible
until you notice the add-on isn't there.
Values are passed to a plist writer, so `null` is not a value: it
renders as a key with nothing under it, which makes the whole file
invalid and drops **every** policy, not just that one. Omit the key
instead.
Example:
```nix
{ DisableTelemetry = true; }
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.zen.tabBridge.enable` [#hauszentabbridgeenable]
`boolean` · default `false`
Deploy haus's own tiny extension into Zen, so the bar can find and
switch to the tab that is making noise.
This is what makes the media pill's ⌘ click land on the **tab** rather
than just bringing Zen forward. Safari and the Chromium browsers need
nothing here — they hand their tab list to AppleScript and the pill uses
that. Firefox and its forks hand out nothing at all, to AppleScript or
to accessibility, so without this the pill falls back to driving
Firefox's own address-bar tab search with synthetic keystrokes, which
needs the Accessibility permission and is exactly as pleasant as it
sounds.
Off by default because it force-installs an add-on into your browser,
which is not a thing haus should do to you unasked. Turning it on
costs one derivation, a native-messaging manifest, and two keys in
haus's root-owned policy plist — one of which is the signature switch
below. Turning it back off stops haus deploying it — what Zen then
does with the add-on already installed is Firefox's policy engine's
business, not haus's, so check `about:addons` and remove it there if
it outstays the option.
**Zen only, and that's a signing constraint rather than a choice.**
Release Firefox refuses an extension Mozilla hasn't signed, and it is
built so that no pref and no policy can say otherwise. Zen is built the
other way (`MOZ_REQUIRE_SIGNING = false`), which is the whole reason
haus can build the `.xpi` itself and install it out of the nix store.
It still costs a switch. Zen carries Firefox's own preference defaults,
which turn signature enforcement back on, so turning this option on also
makes haus lock `xpinstall.signatures.required = false` — for the
browser, not just for its own add-on. Without it Zen refuses the bridge
with `ERROR_SIGNEDSTATE_REQUIRED` and the option quietly does nothing;
with it, an unsigned add-on from anywhere would also install if
something asked. That is the second reason this is off by default.
Firefox support would mean an AMO account and unlisted self-distribution
signing — packaging, not a code change — and would drop the pref.
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
### haus.developer [#hausdeveloper]
The developer pack: the CLI toolbelt, Git tooling and language runtimes. Coding agents left this pack on 2026-08-13 and are their own room now (`haus.ai.*`). Off is a nebelhaus machine for someone who never opens a terminal by choice.
#### `haus.developer.enable` [#hausdeveloperenable]
`boolean` · default `false`
The Development room: the CLI toolbelt, Git tooling and language
runtimes. The neutral catalogue leaves it off; nebelhaus selects it
in its desktop.
Coding agents left this pack on 2026-08-13 and are their own room
now (`haus.ai.*`). The two rooms are independent: a desktop or host
selects each one explicitly.
`false` is what makes a non-developer nebelhaus possible — it strips
those tools rather than merely hiding them. What remains is the
product: `haus`, `awake`, the theme, the terminal, the bar, the tiler
and the palette.
The Git and toolbelt sub-options below default to this value, so a
host can turn the room on and then remove one piece:
haus.developer.enable = true;
haus.developer.git.enable = false; # …but leave Git tooling out
Example:
```nix
true
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.developer.git.enable` [#hausdevelopergitenable]
`boolean` · default `config.haus.developer.enable`
Git and its surroundings: the shell alias vocabulary, the themed git
config, delta (diff pager), lazygit, `gh`, and gnupg for commit
signing. Off drops all of them, and `haus.git.*` then has
nothing to configure.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.developer.languages` [#hausdeveloperlanguages]
`list of value "node" (singular enum)` · default `[ ]`
Language runtimes to install. Currently only "node" (bun + fnm, with
fnm's `--use-on-cd` shell hook).
Deliberately a list rather than one bool per language, so adding
"rust" or "python" later doesn't change this option's shape.
Example:
```nix
[ ]
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.developer.toolbelt.enable` [#hausdevelopertoolbeltenable]
`boolean` · default `config.haus.developer.enable`
The terminal toolbelt: bat, fzf, fd, ripgrep, yazi, zoxide, lsd,
glow, jq, tree, chafa, ttyd and fastfetch — the themed replacements
for cat, find, grep, ls and friends that haus's shell is built
around.
Off leaves a plain shell. The prompt (starship) and the colour scheme
stay: these are the *tools*, not the appearance.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
## Windows [#windows]
Tiling, window navigation, hot corners, and the leader key that launches an app or throws it somewhere. The workspaces themselves (`haus.workspaces`) and the keys haus claims (`haus.keys`) are shared surfaces below, because the bar and the launcher read them too.
### haus.hotCorners [#haushotcorners]
What each corner of the screen does when the pointer reaches it. Every corner is unset by default, so haus never overwrites one you set yourself.
#### `haus.hotCorners.bottomLeft` [#haushotcornersbottomleft]
`null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null`
What happens when the pointer reaches the bottom-left corner of the main
display.
```
disabled nothing happens — the corner is explicitly claimed and left inert
mission-control Mission Control: every window and Space, zoomed out
application-windows App Exposé: every window of the app you're in
desktop push all windows aside and show the desktop
launchpad the grid of installed apps (on macOS 26 this opens the Apps view)
notification-center slide out Notification Center and its widgets
quick-note start a Quick Note — Apple's own default for the bottom-right corner
screen-saver start the screen saver immediately
prevent-screen-saver hold the screen saver off while the pointer rests here
sleep-display put the display to sleep (the machine keeps running)
lock-screen lock the screen and return to the login window
```
null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.
Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.
Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which prowl replaces. They still
work, they just show you a view of the windows prowl is arranging.
Example:
```nix
"mission-control"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.hotCorners.bottomRight` [#haushotcornersbottomright]
`null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null`
What happens when the pointer reaches the bottom-right corner of the main
display.
```
disabled nothing happens — the corner is explicitly claimed and left inert
mission-control Mission Control: every window and Space, zoomed out
application-windows App Exposé: every window of the app you're in
desktop push all windows aside and show the desktop
launchpad the grid of installed apps (on macOS 26 this opens the Apps view)
notification-center slide out Notification Center and its widgets
quick-note start a Quick Note — Apple's own default for the bottom-right corner
screen-saver start the screen saver immediately
prevent-screen-saver hold the screen saver off while the pointer rests here
sleep-display put the display to sleep (the machine keeps running)
lock-screen lock the screen and return to the login window
```
null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.
Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.
Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which prowl replaces. They still
work, they just show you a view of the windows prowl is arranging.
Example:
```nix
"mission-control"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.hotCorners.topLeft` [#haushotcornerstopleft]
`null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null`
What happens when the pointer reaches the top-left corner of the main
display.
```
disabled nothing happens — the corner is explicitly claimed and left inert
mission-control Mission Control: every window and Space, zoomed out
application-windows App Exposé: every window of the app you're in
desktop push all windows aside and show the desktop
launchpad the grid of installed apps (on macOS 26 this opens the Apps view)
notification-center slide out Notification Center and its widgets
quick-note start a Quick Note — Apple's own default for the bottom-right corner
screen-saver start the screen saver immediately
prevent-screen-saver hold the screen saver off while the pointer rests here
sleep-display put the display to sleep (the machine keeps running)
lock-screen lock the screen and return to the login window
```
null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.
Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.
Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which prowl replaces. They still
work, they just show you a view of the windows prowl is arranging.
Example:
```nix
"mission-control"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.hotCorners.topRight` [#haushotcornerstopright]
`null or one of "disabled", "mission-control", "application-windows", "desktop", "launchpad", "notification-center", "quick-note", "screen-saver", "prevent-screen-saver", "sleep-display", "lock-screen"` · default `null`
What happens when the pointer reaches the top-right corner of the main
display.
```
disabled nothing happens — the corner is explicitly claimed and left inert
mission-control Mission Control: every window and Space, zoomed out
application-windows App Exposé: every window of the app you're in
desktop push all windows aside and show the desktop
launchpad the grid of installed apps (on macOS 26 this opens the Apps view)
notification-center slide out Notification Center and its widgets
quick-note start a Quick Note — Apple's own default for the bottom-right corner
screen-saver start the screen saver immediately
prevent-screen-saver hold the screen saver off while the pointer rests here
sleep-display put the display to sleep (the machine keeps running)
lock-screen lock the screen and return to the login window
```
null (the default) writes nothing at all, which is not the same as
"disabled": corners are a setting people have usually already made by
hand, and a desktop that names one it doesn't care about would silently
erase it. Use `"disabled"` to explicitly claim a corner and make it inert.
Setting a corner also clears its MODIFIER key. macOS stores "hold ⌘ for
this corner" separately (`wvous-*-modifier`), and a leftover modifier from
an earlier setup makes a corner you just declared look broken —
nothing happens, because you weren't holding the key nobody told you
about. Corners left at null keep whatever modifier they have.
Worth knowing if you also run tiling: `mission-control` and `desktop` are
macOS's own window and Space management, which prowl replaces. They still
work, they just show you a view of the windows prowl is arranging.
Example:
```nix
"mission-control"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.prowl [#hausprowl]
Tiling window management and the Caps-Lock leader launcher.
#### `haus.prowl.enable` [#hausprowlenable]
`boolean` · default `false`
AeroSpace tiling window management + the leader-key launcher.
This is the room switch: off drops AeroSpace, its launch agent, the
wake-time window re-sort and the key remap entirely. To keep the tiler but
leave the keyboard alone, use haus.keys.leader = "none" and
haus.keys.windowNav = "none" instead of turning the room off.
Declared in
[`modules/prowl/options.nix`](https://github.com/hausfold/haus/blob/main/modules/prowl/options.nix)
.
## Bar [#bar]
The menu bar: where it draws, which pills it carries, and what each one reads.
### haus.menuBar [#hausmenubar]
The stock menu bar: what the clock shows, and which Control Center glyphs sit beside it. (The nebelhaus bar itself is `sill`.)
#### `haus.menuBar.clock.analog` [#hausmenubarclockanalog]
`null or boolean` · default `null`
Draw an analog clock face instead of a digital readout. null (the
default) leaves macOS's own choice alone (digital).
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.clock.format` [#hausmenubarclockformat]
`null or one of "12h", "24h"` · default `null`
12-hour or 24-hour menu bar clock. null (the default) leaves
macOS's own choice alone (region-dependent, usually 12h in the US).
Example:
```nix
"24h"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.clock.showDate` [#hausmenubarclockshowdate]
`null or one of "when-space-allows", "always", "never"` · default `null`
Whether the full date appears next to the time. null (the default)
leaves macOS's own choice alone ("when-space-allows").
Example:
```nix
"always"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.clock.showDayOfWeek` [#hausmenubarclockshowdayofweek]
`null or boolean` · default `null`
Show the day of the week next to the clock. null (the default)
leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.clock.showSeconds` [#hausmenubarclockshowseconds]
`null or boolean` · default `null`
Show the clock to second precision instead of minutes. null (the
default) leaves macOS's own choice alone.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.airdrop` [#hausmenubarcontrolcenterairdrop]
`null or boolean` · default `null`
Whether the AirDrop control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.batteryPercentage` [#hausmenubarcontrolcenterbatterypercentage]
`null or boolean` · default `null`
Show the battery percentage next to its menu bar icon. null (the
default) leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.bluetooth` [#hausmenubarcontrolcenterbluetooth]
`null or boolean` · default `null`
Whether the Bluetooth control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.displayBrightness` [#hausmenubarcontrolcenterdisplaybrightness]
`null or boolean` · default `null`
Whether the Screen Brightness control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.focus` [#hausmenubarcontrolcenterfocus]
`null or boolean` · default `null`
Whether the Focus control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.nowPlaying` [#hausmenubarcontrolcenternowplaying]
`null or boolean` · default `null`
Whether the Now Playing control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.menuBar.controlCenter.sound` [#hausmenubarcontrolcentersound]
`null or boolean` · default `null`
Whether the Sound control has a menu bar icon of its own. null (the default) leaves macOS's own choice alone.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.sill [#haussill]
The menu bar, and which pills it draws.
#### `haus.sill.aiUsage.provider` [#haussillaiusageprovider]
`one of "latest", "claude", "codex", "opencode"` · default `"latest"`
Which AI provider to display in the main pill: `latest` (default, automatically
shows whichever provider reported most recently), or one of
`claude`, `codex`, `opencode`.
Clicking the pill always displays the full dropdown with all reporting providers.
Note this is about *usage readouts*, not about which client `holt` can
spawn: a provider reports here whenever it has data for your account —
Codex notably does so from a ChatGPT login alone, with no CLI installed
— so it is deliberately not tied to `haus.ai.clients`.
Example:
```nix
"claude"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.battery.hideOver` [#haussillbatteryhideover]
`null or signed integer` · default `null`
Hide the battery pill when charge percentage is above this threshold
(e.g., set to 80 to show the battery pill only when charge is at or below 80%).
Example:
```nix
80
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.enable` [#haussillbottomenable]
`boolean` · default `false`
Draw a SECOND bar along the bottom of the screen, at the same time as
the menu bar one. `haus.sill.bottom.items` picks what goes on it and
which of its three groups — left, center, right — each pill lands in; an
empty set draws an empty strip, which the module warns about.
SketchyBar has no two-bars-in-one-process mode — an instance is named
after `basename(argv[0])` and keys both its lock file and its mach
service on that name — so this is a second launchd agent running the
SAME binary under a second name, `sill-bottom`. That name is also the
CLI for it: `sill-bottom --set cpu label=…` talks to the bottom bar the
way `sketchybar --set` talks to the menu bar one.
Two things macOS does not do for you here. It reserves the top strip of
every display for the menu bar but reserves NOTHING at the bottom, so
windows would sit under this bar: prowl carves the room out of its
outer-bottom gap whenever this is on (with `haus.prowl.enable = false`,
nothing reserves it and your windows will run underneath). And the Dock,
if you keep it at the bottom, shares that edge — move it to a side, or
leave it hidden.
Example:
```nix
true
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items` [#haussillbottomitems]
`submodule` · default `{ }`
Which pills the bottom bar draws, and WHERE along it — one value each,
all default false. A pill named here MOVES: it is drawn on the bottom
bar and not on the menu bar, whatever `haus.sill.items` says about it —
so there is one switch per pill per bar and never two copies of the
same readout.
Each value is `false` (not on this bar), one of `"left"`, `"center"`,
`"right"` — the bar's three groups — or `true`, which is `"right"`:
haus.sill.bottom.items = \{
agents = "left";
media = "center";
clock = "right";
cpu = true; # same as "right"
};
Within a group the order is fixed (the same order the menu bar uses),
and each group packs outward from its own edge: on the `right` the
first pill sits furthest right, exactly as `clock` does up top, while
`left` fills rightward from the left edge and `center` grows around the
middle of the screen. All three are offered here and only `right` is
offered on the menu bar, because this strip has nothing else on it:
no workspace pills, no front-app slot, and no notch across its middle.
The set is the five core pills (`clock`, `weather`, `media`, `battery`,
`wifi`) plus the `haus.sill.items` extras (`cpu`, `memory`, `volume`,
`calendar`, `caffeinate`, `agents`, `aiUsage`, `elgato`, `harvest`), plus
the Hush pill when `haus.hush.enable` is on. The whole left side
(workspace pills, front app, the leader picker) and the tour stay on the
menu bar.
Needs `haus.sill.bottom.enable`; without it nothing here is drawn.
Example:
```nix
{
agents = "left";
clock = "right";
media = "center";
}
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.agents` [#haussillbottomitemsagents]
`boolean or one of "left", "center", "right"` · default `false`
A paw pill tracking your agent-worktree panes. The label always names the state worth interrupting you for — "2 ready" outranks "5 working", which outranks "1 done" — never a bare count you'd have to click to decode. Click for the per-agent breakdown, sorted the same way (waiting first, then working, then idle, longest-elapsed first within each), each block showing the client, how long it's sat in that state, and — when the pane's checkout is a `holt` lane — its repo and PR status: merged, `+N unshipped` (exactly what `holt reship` fixes), not yet landed, or a dirty-tree footnote. A summary header totals the counts once more than one agent is running. Left-click a row to jump to that pane, ⌥/right-click for a live `zellij subscribe` peek. Fed by each client's own lifecycle hooks, which all call `agent-state` (also installed as \~/.config/sketchybar/plugins/agents-hook.sh): Opencode's plugin and Codex's \~/.codex/hooks.json are written for you (Codex asks you to trust its hooks the first time it sees them), while Claude Code's four agent-state hooks stay yours to point at it in \~/.claude/settings.json — Claude owns that file and rewrites it, so haus merges in only the keys it must and never touches those four. (The two worktree hooks ARE declared, in hearth: they point at a haus-controlled path and self-heal on rebuild.) A row whose zellij pane is gone drops off by itself, which is what stands in for the session-end event Codex doesn't have. Dormant until a client fires.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.aiUsage` [#haussillbottomitemsaiusage]
`boolean or one of "left", "center", "right"` · default `false`
A gauge pill showing AI usage (Claude Code/Codex subscription rate limits as %, or Opencode API token cost as daily $). Automatically shows whichever provider reported most recently. Click for expanded session/weekly limits and daily/monthly API costs with model breakdowns. Claude and Opencode are read off disk; Codex has no local usage data, so its row is polled from your ChatGPT account with the OAuth token in \~/.codex/auth.json (refreshed and rewritten in place) — no Codex login on the machine, no call is made. Claude's row is pushed by its statusline; the Codex and Opencode rows are pulled by the pill itself on a 3-minute TTL, so they stay current on a machine that never opens Claude at all. Claude and Opencode also get a `tokens` block in the dropdown — raw tokens moved today, this week, this month and all time (cache reads and all), two periods to a line so a full set reads as a 2×2, purely for the fun of watching the number climb. A period with nothing in it is left out rather than printed as a zero, so the block simply gets smaller, and a closing `∑ Everything` adds every provider up when more than one is reporting. It is a score, not a limit: nothing acts on it, and it never reaches the pill's own label. Claude's is summed from your transcripts on a 15-minute TTL behind an index, so only sessions that grew since the last pass are re-read; Codex has no row because it keeps no local history to count.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.battery` [#haussillbottomitemsbattery]
`boolean or one of "left", "center", "right"` · default `false`
The battery pill.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.caffeinate` [#haussillbottomitemscaffeinate]
`boolean or one of "left", "center", "right"` · default `false`
A coffee pill that prevents idle system sleep for 1/2/4/8 hours, a custom whole-hour duration, or indefinitely. The display may still turn off; closing a MacBook lid still sleeps it. Uses macOS's built-in `caffeinate`, so there is no extra package.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.calendar` [#haussillbottomitemscalendar]
`boolean or one of "left", "center", "right"` · default `false`
The one meeting you have to be at next, and one gesture to join it. It reads "in 12m · Design review" — countdown first, because a label is clipped from the END and the number is the part you must never lose; below `haus.sill.calendar.preciseUnder` hours it carries minutes, above it just "in 14h" or "in 2d", and while an event is running it says "now · …" instead of going blank. For `haus.sill.calendar.imminent` minutes either side of the start the whole pill FILLS with the accent — a shape change rather than a colour change, so it catches the eye you aren't pointing at it. RIGHT-CLICK joins: it opens the event's conferencing link, found in the invite's url, location or notes (Meet, Zoom, Teams, Webex, Jitsi, Whereby and friends out of the box; `haus.sill.calendar.joinHosts` adds your own). LEFT-CLICK opens the day as a timeline — what's DONE in the last `haus.sill.calendar.past` hours, what's on NOW, and what's NEXT — each event carrying its day, clock time, length and who it's with, the next one boxed, and a `Join` affordance on every row that has a link. Your own address is dropped from the "with" line automatically: a CalDAV calendar is named for the account it syncs, so the pill can work out which attendee is you with no configuration (`haus.sill.calendar.me` for the cases where it can't). A name too long for the pill sweeps past only while you HOVER it — nothing here starts a marquee on its own — and `haus.sill.calendar.width` sets how much room it gets before that applies. Pulls in `ical-buddy` automatically and reads Calendar, so macOS prompts for Calendar access on first run.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.clock` [#haussillbottomitemsclock]
`boolean or one of "left", "center", "right"` · default `false`
The clock pill, pinned to the far right.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.cpu` [#haussillbottomitemscpu]
`boolean or one of "left", "center", "right"` · default `false`
Total CPU load, drawn as a graph pill: the last two minutes of it behind the number, because a percentage on its own can't tell a spike settling from a climb that started five minutes ago. The reading is a DELTA between samples — the `ps` sum this used to print is each process's average over its whole lifetime, which on a machine that has been up a week barely moves while every core is pinned. LEFT-CLICK opens a dropdown: the user/system split, the load average, then what's responsible, biggest first and aggregated per app so a browser's twenty helpers are one row; clicking a row focuses that app's window. RIGHT-CLICK opens Activity Monitor on its CPU tab. The rows can only cover processes you own, so anything root runs — `kernel_task`, `WindowServer` — lands in `everything else` rather than going quietly missing from the sum.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.elgato` [#haussillbottomitemselgato]
`boolean or one of "left", "center", "right"` · default `false`
Toggles an Elgato Key Light on the local network. The light is found over mDNS (or pinned with `haus.sill.elgato.host`), and the pill draws dim when it can't be reached at all — a light that dropped off the wifi is not the same thing as a light that's switched off.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.github` [#haussillbottomitemsgithub]
`boolean or one of "left", "center", "right"` · default `false`
One number from GitHub, and the rows behind it. The pill is configured as a list of typed SOURCES (`haus.sill.github.sources`) — a `search` filter, the `ci` board, or your own `command` — and its label is whichever source is worth interrupting you for: the highest-severity one with a nonzero count, earliest in the list on a tie. With nothing to report it draws no number at all rather than a zero, because a number you never act on is a number you stop seeing. LEFT-CLICK opens the dropdown, one section per source, each row clicking through to the PR or repo on github.com; RIGHT-CLICK refreshes now, as does the `Refresh` row at the bottom of the dropdown, which also says how old the numbers are. The `ci` source is the one thing gh-dash cannot show you: GitHub's search index carries no workflow runs, so "did main's last run pass" is only reachable as the check rollup of the default branch's head commit, in GraphQL — which is exactly what that source asks for, in one query for the whole owner. Needs `haus.developer.git.enable` (an assertion enforces it) for the `gh` it queries through, and a `gh auth login` you have run: not logged in, the pill says `auth` and its dropdown hands you that command rather than drawing a silent zero. Never fetches on the bar's tick — the tick renders a cache and detaches the network call — so a slow GitHub costs a stale number, never a stalled bar.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.harvest` [#haussillbottomitemsharvest]
`boolean or one of "left", "center", "right"` · default `false`
A Harvest time-tracking pill; needs a \~/.config/sketchybar/harvest\_secrets.sh you provide.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.hush` [#haussillbottomitemshush]
`boolean or one of "left", "center", "right"` · default `false`
The Hush (Do-Not-Disturb) pill. Needs `haus.hush.enable`; setting this moves the pill but does not enable the Hush room by itself.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.media` [#haussillbottomitemsmedia]
`boolean or one of "left", "center", "right"` · default `false`
The now-playing track — auto-hides when nothing plays, dims when paused, and counts DOWN instead of scrolling a title once the thing playing is longer than twenty minutes (a podcast or a video is one you already know the name of; what you keep glancing at the bar for is how much is left). The title scrolls for a few seconds after a track changes and then settles, so nothing moves in the corner of your eye forever; hovering brings the full title back. Gestures: left click the dropdown, RIGHT click play/pause, ⌥ next, ⇧ previous, ⌘ jump to whatever is making the noise, scroll to seek ±10s. That ⌘ click reaches the browser TAB, not just the browser: the track's title is matched against the open tabs through Safari's and Chromium's AppleScript tab APIs, and on a Firefox fork (Zen among them) — which expose no tab list at all, neither to AppleScript nor to accessibility — through Firefox's own open-tab search in the address bar. Both routes ask for a permission the first time they run, Automation for the scriptable browsers and Accessibility for the Firefox forks, and both quietly fall back to just fronting the app if you say no. The dropdown carries the cover when the source published one, a scrubbable position slider, and transport rows — plus, for a source with no cover, a small app-icon badge floating in its bottom-right corner. It reads the same system-wide session Control Center does, so it follows a browser tab as readily as Apple Music or Spotify, and its icon says what KIND of thing is playing: an app it recognises gets that app's glyph, a browser gets video or music depending on whether an album was published. It cannot say which SITE — no URL reaches the now-playing session and none of window titles, artwork shape or the session's pid can recover one, so a wrong YouTube glyph on a Netflix tab is a guess this deliberately doesn't make; `haus.sill.media.icons` is the override for a machine that knows better. SketchyBar's own `media_change` event has been dead since macOS 15.4, where Apple started requiring an entitlement to talk to `mediaremoted`; the pill is fed instead by `media-control`, which does the read from inside the entitled `/usr/bin/perl`. That is a private-framework route Apple could close in any point release — `media-control test` exits non-zero once it has.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.memory` [#haussillbottomitemsmemory]
`boolean or one of "left", "center", "right"` · default `false`
Memory in use, drawn as a graph pill. It counts what Activity Monitor counts — app memory + wired + compressed — and deliberately NOT the file cache: macOS fills idle RAM with cache on purpose, and the old reading counted that as used, which is why it sat near 90% on a machine doing nothing. The pill's COLOUR is the kernel's own pressure level (green normal, amber warning, red critical) rather than the percentage, because 60% of RAM in use is a Mac working correctly and a pill that goes amber for it is a pill you learn to ignore. LEFT-CLICK opens a dropdown with used/total, the cache, compressed and swap figures and then the biggest footprints per app, each row clicking through to that app's window. RIGHT-CLICK opens Activity Monitor on its Memory tab.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.volume` [#haussillbottomitemsvolume]
`boolean or one of "left", "center", "right"` · default `false`
Output volume / mute state.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.weather` [#haussillbottomitemsweather]
`boolean or one of "left", "center", "right"` · default `false`
The weather pill and its click-to-open forecast popover.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.bottom.items.wifi` [#haussillbottomitemswifi]
`boolean or one of "left", "center", "right"` · default `false`
The Wi-Fi status pill.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.horizon` [#haussillcalendarhorizon]
`positive integer, meaning >0` · default `24`
How far ahead the `calendar` pill looks, in HOURS. Nothing starting
later than this makes it say anything but "No events".
It is a limit on the PILL, not on the dropdown: the timeline still lists
what's coming past the horizon, because a list you opened on purpose is
allowed to tell you about Thursday.
Example:
```nix
12
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.imminent` [#haussillcalendarimminent]
`positive integer, meaning >0` · default `5`
How many MINUTES either side of an event's start the `calendar` pill
fills solid — accent background, dark type — for a window of twice this
in total.
Deliberately tied to the START and not to the whole meeting: five
minutes before is "go now" and five after is "you're late", and they are
the same fact. A pill that stayed filled for the event's full hour would
just be a pill that is a different colour.
Example:
```nix
2
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.joinHosts` [#haussillcalendarjoinhosts]
`list of string` · default `[ ]`
Extra hostnames to treat as conferencing links, on top of the built-in
set (Google Meet, Zoom, Teams, Webex, Jitsi, Whereby, Chime, BlueJeans,
GoTo, Around, Discord). Right-clicking the pill — or clicking a dropdown
row — opens the first link in the invite whose host matches.
Matching is on the HOST, and a bare registrable name also covers its
subdomains (`zoom.us` catches `us02web.zoom.us`). That is why it isn't a
substring search: every Google Meet invite also carries a `tel.meet`
dial-in and a `support.google.com` footer, and looking for "meet"
anywhere in the notes opens the phone-number page.
Example:
```nix
[
"meet.mycorp.example"
]
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.me` [#haussillcalendarme]
`list of string` · default `[ ]`
Addresses (or display names) that are YOU, dropped from the "with …"
line in the dropdown. An attendee list that includes you is a list that
tells you nothing — every meeting is "with you and Ana".
Usually unnecessary: a CalDAV account's calendar is named for the
address it syncs, so the pill takes the calendar names that look like
email addresses as its answer and re-checks them every six hours. Set
this when that guess misses — a local calendar, an alias you're invited
under, or a second address on the same account. It ADDS to what was
found rather than replacing it.
Example:
```nix
[
"you@work.example"
]
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.past` [#haussillcalendarpast]
`positive integer, meaning >0` · default `24`
How many HOURS of finished events the dropdown's `Done` band keeps.
The band exists so the timeline has a floor to read up from — "what have
I already been in today" is the context that makes "next" mean anything.
The pill itself never looks backwards.
Example:
```nix
8
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.preciseUnder` [#haussillcalendarpreciseunder]
`positive integer, meaning >0` · default `12`
Below how many HOURS the countdown carries minutes.
Under it the pill reads "in 3h20m"; at or above it, "in 14h", "in 2d".
A number you are reading as "not yet" doesn't need its minutes, and the
digits it drops are the ones a long meeting name would have eaten.
Example:
```nix
3
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.refresh` [#haussillcalendarrefresh]
`positive integer, meaning >0` · default `15`
How often the `calendar` pill re-reads your calendar, in SECONDS.
This was 60, which is the worst possible number for a pill whose whole
job is a countdown in minutes: the displayed number was up to a minute
stale, so "in 1m" could mean the meeting started fifty seconds ago, and
an event you had just accepted took a minute to appear at all. One read
costs about 50ms of `icalBuddy`, so paying it four times a minute is
cheaper than being wrong.
Hovering the pill forces a read regardless of this, which is the case
that actually matters — looking at it is the moment it has to be right.
Example:
```nix
60
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.upcoming` [#haussillcalendarupcoming]
`positive integer, meaning >0` · default `5`
How many future events the dropdown's `Next` band lists, at most. The
first of them is the one the pill is about, and the one drawn in a box.
Example:
```nix
3
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.calendar.width` [#haussillcalendarwidth]
`positive integer, meaning >0` · default `32`
How wide the `calendar` pill's label is allowed to get, in CHARACTERS —
not pixels. The label reads "in 12m · \"; anything longer is
clipped to this, and sweeps past in full while you hover the pill.
The countdown leads deliberately: the clip eats the END of a label, so
the number the pill exists for has to sit in front of the part that can
run long.
It is a MAXIMUM, not a fixed size — a short event name still draws a
short pill.
Example:
```nix
16
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.clock.mode` [#haussillclockmode]
`one of "full", "compact"` · default `"full"`
The display mode for the clock pill: `full` (default, e.g. "Fri Jul 31 09:41 AM" with calendar icon)
or `compact` (e.g. "Fri 31/7 9:41" without icon and trimmed spacing).
Example:
```nix
"compact"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.clock.monoFont` [#haussillclockmonofont]
`boolean` · default `true`
Whether the clock pill's date and time use `haus.fonts.mono.name`, like
the rest of Sill. Disable this to use macOS's system UI font, whose zero
has no dot and is easier to distinguish from an 8 at a glance. The
calendar icon remains in the Nerd Font either way.
Example:
```nix
false
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.elgato.host` [#haussillelgatohost]
`string` · default `""`
Which Elgato Key Light the `elgato` pill toggles — a hostname or IP,
optionally with a `:port` (the light's HTTP API is on 9123).
Empty (the default) means discover it: the pill browses mDNS for
`_elg._tcp`, caches what it found in
`~/.local/state/nebelhaus/elgato-host`, and re-browses at most once a
minute whenever the light stops answering — so a light that took a new
DHCP address comes back on its own, without a rebuild. Pin this when
you have more than one light, when the light has a static lease, or
when mDNS is unreliable on your network.
Example:
```nix
"elgato-key-light-mini-57a3.local"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.enable` [#haussillenable]
`boolean` · default `false`
The SketchyBar menu bar. When off, the native macOS menu bar is kept
(nebelhaus stops hiding it) and no bar is drawn.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.refresh` [#haussillgithubrefresh]
`integer between 60 and 3600 (both inclusive)` · default `300`
How often, in seconds, the `github` pill re-asks GitHub. The floor is
60 and it is enforced by the type rather than written down: this is the
first pill in the bar that crosses the network, and GitHub's authed
budget is 30 search requests a minute and 5000 GraphQL points an hour —
shared with every other `gh` on the machine, `gh-dash` included.
The pill never fetches on the bar's tick. The tick renders a cache and,
if it has gone stale, detaches the fetch; so this is how old a number
may be, not how long anything waits.
Example:
```nix
900
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources` [#haussillgithubsources]
`list of (submodule)` · see below
What the `github` pill counts, in the order it prefers to speak about
them. Each entry names exactly ONE kind — `search`, `ci` or `command` —
and the pill owns the query for that kind.
It is typed rather than one free-form query string because the two
questions have no common shape. A `search` is a GitHub search filter and
comes back as a count plus rows with a title, a repo and a URL; the CI
board does not exist in that index at all — GitHub's search carries no
workflow runs — and is only reachable as the `statusCheckRollup` of a
default branch's head commit, in GraphQL. Handing the option a raw
GraphQL query instead would move the problem rather than solve it: the
result is an arbitrary tree, so the pill would also need paired jq paths
for the count, the rows and the state, and every one of them would fail
at runtime inside a bar plugin, where the only symptom is a pill that
draws nothing. `command` is the escape hatch, and it is a command rather
than a query for the same reason: it can do the fetching AND the
shaping, and you can run it in a terminal to see why it is wrong.
Example:
```nix
[
{ ci = true; } # red default branches
{ search = "org:hausfold is:pr is:open"; } # the working queue
{
search = "org:hausfold is:pr is:open status:failure"; # …and the red half of it
title = "red PRs";
severity = "bad";
}
]
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.ci` [#haussillgithubsourcesci]
`boolean` · default `false`
Every repo the owner has, its default branch, and whether that branch's
head commit is green — one GraphQL query for the whole owner, counting
the ones that came back FAILURE or ERROR. Archived repos are skipped; a
repo with no checks at all is not a failure and is not counted.
This is the source that exists because search cannot answer it. It is
also why the pill is a pill: it is the one GitHub question with no
`gh-dash` tab, since a dashboard section IS a search filter.
Example:
```nix
true
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.command` [#haussillgithubsourcescommand]
`null or string` · default `null`
A command run through `bash -c`, printing one row per line as
`\t` or `\t\t`, where state is `ok`,
`warn` or `bad`. The count is the number of rows; a line that doesn't
match the shape is dropped rather than drawn mangled.
It runs from the bar's detached fetch, i.e. under launchd's environment
and not your interactive shell: name binaries by absolute path or expect
them missing. Host-only — a desktop may not set it, since it is
arbitrary code rather than data.
Example:
```nix
"gh api /notifications --jq '.[] | \"warn\\t\" + .subject.title'"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.icon` [#haussillgithubsourcesicon]
`string` · default `""`
The glyph beside that section's heading, in the bar's Nerd Font. Empty
takes a default for the kind.
Example:
```nix
""
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.limit` [#haussillgithubsourceslimit]
`integer between 1 and 100 (both inclusive)` · default `8`
How many rows this source may put in the dropdown. Also what a `search`
asks GitHub for per page — there is no point paging in a hundred hits to
draw eight of them. The count is unaffected: it is the real total, and
the dropdown says how many rows it left out.
Example:
```nix
5
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.org` [#haussillgithubsourcesorg]
`string` · default `""`
Which owner the `ci` source asks about. Empty (the default) follows
`haus.git.org`, which is where it should normally come from — an owner
that renames is then one word for the whole machine. Set it only to
point one source at a second owner.
Example:
```nix
"hausfold"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.search` [#haussillgithubsourcessearch]
`null or string` · default `null`
A GitHub issue/PR search filter, exactly as you would type it into
github.com's search box. The count is the search's own `total_count`,
which can be larger than the rows shown — the dropdown says how many it
left out rather than letting a truncated list read as a complete one.
The string is literal: nothing interpolates `haus.git.org` into it for
you, because a machine that reads several owners is the reason that
option is allowed to be empty. Write the `org:` in.
Example:
```nix
"org:hausfold is:pr is:open"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.severity` [#haussillgithubsourcesseverity]
`null or one of "info", "warn", "bad"` · default \`\`bad`for a`ci`source,`info` for the others`
How much this source's count matters, which decides both its colour
(`info` neutral, `warn` peach, `bad` red) and which source the PILL
speaks for when more than one has something to say — highest severity
first, then list order.
It is per-source rather than global because the same kind means
different things in two entries: `is:pr is:open` is a work queue and
`is:pr is:open status:failure` is an alarm, and both are searches.
Example:
```nix
"bad"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.github.sources.*.title` [#haussillgithubsourcestitle]
`string` · default `""`
The dropdown section's heading. Empty derives one: the owner and
"default branches" for `ci`, the filter itself for `search`.
Example:
```nix
"red on main"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items` [#haussillitems]
`submodule` · default `{ }`
Which SketchyBar pills to draw, one bool each. The core pills —
`clock`, `weather`, `media`, `battery`, `wifi` — default true; the extras
— the readouts `cpu`, `memory`, `volume`, `calendar`, `caffeinate`
and the personal `agents`, `aiUsage`, `elgato`, `harvest` —
default false. Set
only what you want to change:
haus.sill.items = \{
weather = false; # drop a default-on core pill
cpu = true; # add an off-by-default readout
caffeinate = true; # add the keep-awake controller
};
A pill set false is never created (its update script doesn't run either).
The hush (Do-Not-Disturb) pill is separate — it rides
haus.hush.enable, not this set. It can still be moved to the second bar
with `haus.sill.bottom.items.hush`.
This is the MENU BAR's set, and it is one group: the movable pills all
sit on the right, because its left is the workspace pills, the front app
and the leader picker, and its center is kept clear — that is the one
span a MacBook's notch covers when the bar is at the top, which is where
it is by default. `haus.sill.bottom.items` mirrors these pills
for the optional second bar, also accepts `hush`, and takes a side
(`"left"` / `"center"` / `"right"`) rather than a bare bool; a pill named
there moves down rather than being drawn twice.
Example:
```nix
{
cpu = true;
weather = false;
}
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.agents` [#haussillitemsagents]
`boolean` · default `false`
A paw pill tracking your agent-worktree panes. The label always names the state worth interrupting you for — "2 ready" outranks "5 working", which outranks "1 done" — never a bare count you'd have to click to decode. Click for the per-agent breakdown, sorted the same way (waiting first, then working, then idle, longest-elapsed first within each), each block showing the client, how long it's sat in that state, and — when the pane's checkout is a `holt` lane — its repo and PR status: merged, `+N unshipped` (exactly what `holt reship` fixes), not yet landed, or a dirty-tree footnote. A summary header totals the counts once more than one agent is running. Left-click a row to jump to that pane, ⌥/right-click for a live `zellij subscribe` peek. Fed by each client's own lifecycle hooks, which all call `agent-state` (also installed as \~/.config/sketchybar/plugins/agents-hook.sh): Opencode's plugin and Codex's \~/.codex/hooks.json are written for you (Codex asks you to trust its hooks the first time it sees them), while Claude Code's four agent-state hooks stay yours to point at it in \~/.claude/settings.json — Claude owns that file and rewrites it, so haus merges in only the keys it must and never touches those four. (The two worktree hooks ARE declared, in hearth: they point at a haus-controlled path and self-heal on rebuild.) A row whose zellij pane is gone drops off by itself, which is what stands in for the session-end event Codex doesn't have. Dormant until a client fires.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.aiUsage` [#haussillitemsaiusage]
`boolean` · default `false`
A gauge pill showing AI usage (Claude Code/Codex subscription rate limits as %, or Opencode API token cost as daily $). Automatically shows whichever provider reported most recently. Click for expanded session/weekly limits and daily/monthly API costs with model breakdowns. Claude and Opencode are read off disk; Codex has no local usage data, so its row is polled from your ChatGPT account with the OAuth token in \~/.codex/auth.json (refreshed and rewritten in place) — no Codex login on the machine, no call is made. Claude's row is pushed by its statusline; the Codex and Opencode rows are pulled by the pill itself on a 3-minute TTL, so they stay current on a machine that never opens Claude at all. Claude and Opencode also get a `tokens` block in the dropdown — raw tokens moved today, this week, this month and all time (cache reads and all), two periods to a line so a full set reads as a 2×2, purely for the fun of watching the number climb. A period with nothing in it is left out rather than printed as a zero, so the block simply gets smaller, and a closing `∑ Everything` adds every provider up when more than one is reporting. It is a score, not a limit: nothing acts on it, and it never reaches the pill's own label. Claude's is summed from your transcripts on a 15-minute TTL behind an index, so only sessions that grew since the last pass are re-read; Codex has no row because it keeps no local history to count.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.battery` [#haussillitemsbattery]
`boolean` · default `true`
The battery pill.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.caffeinate` [#haussillitemscaffeinate]
`boolean` · default `false`
A coffee pill that prevents idle system sleep for 1/2/4/8 hours, a custom whole-hour duration, or indefinitely. The display may still turn off; closing a MacBook lid still sleeps it. Uses macOS's built-in `caffeinate`, so there is no extra package.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.calendar` [#haussillitemscalendar]
`boolean` · default `false`
The one meeting you have to be at next, and one gesture to join it. It reads "in 12m · Design review" — countdown first, because a label is clipped from the END and the number is the part you must never lose; below `haus.sill.calendar.preciseUnder` hours it carries minutes, above it just "in 14h" or "in 2d", and while an event is running it says "now · …" instead of going blank. For `haus.sill.calendar.imminent` minutes either side of the start the whole pill FILLS with the accent — a shape change rather than a colour change, so it catches the eye you aren't pointing at it. RIGHT-CLICK joins: it opens the event's conferencing link, found in the invite's url, location or notes (Meet, Zoom, Teams, Webex, Jitsi, Whereby and friends out of the box; `haus.sill.calendar.joinHosts` adds your own). LEFT-CLICK opens the day as a timeline — what's DONE in the last `haus.sill.calendar.past` hours, what's on NOW, and what's NEXT — each event carrying its day, clock time, length and who it's with, the next one boxed, and a `Join` affordance on every row that has a link. Your own address is dropped from the "with" line automatically: a CalDAV calendar is named for the account it syncs, so the pill can work out which attendee is you with no configuration (`haus.sill.calendar.me` for the cases where it can't). A name too long for the pill sweeps past only while you HOVER it — nothing here starts a marquee on its own — and `haus.sill.calendar.width` sets how much room it gets before that applies. Pulls in `ical-buddy` automatically and reads Calendar, so macOS prompts for Calendar access on first run.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.claudeUsage` [#haussillitemsclaudeusage]
`boolean` · default `false`
Deprecated alias for `aiUsage`.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.clock` [#haussillitemsclock]
`boolean` · default `true`
The clock pill, pinned to the far right.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.cpu` [#haussillitemscpu]
`boolean` · default `false`
Total CPU load, drawn as a graph pill: the last two minutes of it behind the number, because a percentage on its own can't tell a spike settling from a climb that started five minutes ago. The reading is a DELTA between samples — the `ps` sum this used to print is each process's average over its whole lifetime, which on a machine that has been up a week barely moves while every core is pinned. LEFT-CLICK opens a dropdown: the user/system split, the load average, then what's responsible, biggest first and aggregated per app so a browser's twenty helpers are one row; clicking a row focuses that app's window. RIGHT-CLICK opens Activity Monitor on its CPU tab. The rows can only cover processes you own, so anything root runs — `kernel_task`, `WindowServer` — lands in `everything else` rather than going quietly missing from the sum.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.elgato` [#haussillitemselgato]
`boolean` · default `false`
Toggles an Elgato Key Light on the local network. The light is found over mDNS (or pinned with `haus.sill.elgato.host`), and the pill draws dim when it can't be reached at all — a light that dropped off the wifi is not the same thing as a light that's switched off.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.github` [#haussillitemsgithub]
`boolean` · default `false`
One number from GitHub, and the rows behind it. The pill is configured as a list of typed SOURCES (`haus.sill.github.sources`) — a `search` filter, the `ci` board, or your own `command` — and its label is whichever source is worth interrupting you for: the highest-severity one with a nonzero count, earliest in the list on a tie. With nothing to report it draws no number at all rather than a zero, because a number you never act on is a number you stop seeing. LEFT-CLICK opens the dropdown, one section per source, each row clicking through to the PR or repo on github.com; RIGHT-CLICK refreshes now, as does the `Refresh` row at the bottom of the dropdown, which also says how old the numbers are. The `ci` source is the one thing gh-dash cannot show you: GitHub's search index carries no workflow runs, so "did main's last run pass" is only reachable as the check rollup of the default branch's head commit, in GraphQL — which is exactly what that source asks for, in one query for the whole owner. Needs `haus.developer.git.enable` (an assertion enforces it) for the `gh` it queries through, and a `gh auth login` you have run: not logged in, the pill says `auth` and its dropdown hands you that command rather than drawing a silent zero. Never fetches on the bar's tick — the tick renders a cache and detaches the network call — so a slow GitHub costs a stale number, never a stalled bar.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.harvest` [#haussillitemsharvest]
`boolean` · default `false`
A Harvest time-tracking pill; needs a \~/.config/sketchybar/harvest\_secrets.sh you provide.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.media` [#haussillitemsmedia]
`boolean` · default `true`
The now-playing track — auto-hides when nothing plays, dims when paused, and counts DOWN instead of scrolling a title once the thing playing is longer than twenty minutes (a podcast or a video is one you already know the name of; what you keep glancing at the bar for is how much is left). The title scrolls for a few seconds after a track changes and then settles, so nothing moves in the corner of your eye forever; hovering brings the full title back. Gestures: left click the dropdown, RIGHT click play/pause, ⌥ next, ⇧ previous, ⌘ jump to whatever is making the noise, scroll to seek ±10s. That ⌘ click reaches the browser TAB, not just the browser: the track's title is matched against the open tabs through Safari's and Chromium's AppleScript tab APIs, and on a Firefox fork (Zen among them) — which expose no tab list at all, neither to AppleScript nor to accessibility — through Firefox's own open-tab search in the address bar. Both routes ask for a permission the first time they run, Automation for the scriptable browsers and Accessibility for the Firefox forks, and both quietly fall back to just fronting the app if you say no. The dropdown carries the cover when the source published one, a scrubbable position slider, and transport rows — plus, for a source with no cover, a small app-icon badge floating in its bottom-right corner. It reads the same system-wide session Control Center does, so it follows a browser tab as readily as Apple Music or Spotify, and its icon says what KIND of thing is playing: an app it recognises gets that app's glyph, a browser gets video or music depending on whether an album was published. It cannot say which SITE — no URL reaches the now-playing session and none of window titles, artwork shape or the session's pid can recover one, so a wrong YouTube glyph on a Netflix tab is a guess this deliberately doesn't make; `haus.sill.media.icons` is the override for a machine that knows better. SketchyBar's own `media_change` event has been dead since macOS 15.4, where Apple started requiring an entitlement to talk to `mediaremoted`; the pill is fed instead by `media-control`, which does the read from inside the entitled `/usr/bin/perl`. That is a private-framework route Apple could close in any point release — `media-control test` exits non-zero once it has.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.memory` [#haussillitemsmemory]
`boolean` · default `false`
Memory in use, drawn as a graph pill. It counts what Activity Monitor counts — app memory + wired + compressed — and deliberately NOT the file cache: macOS fills idle RAM with cache on purpose, and the old reading counted that as used, which is why it sat near 90% on a machine doing nothing. The pill's COLOUR is the kernel's own pressure level (green normal, amber warning, red critical) rather than the percentage, because 60% of RAM in use is a Mac working correctly and a pill that goes amber for it is a pill you learn to ignore. LEFT-CLICK opens a dropdown with used/total, the cache, compressed and swap figures and then the biggest footprints per app, each row clicking through to that app's window. RIGHT-CLICK opens Activity Monitor on its Memory tab.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.volume` [#haussillitemsvolume]
`boolean` · default `false`
Output volume / mute state.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.weather` [#haussillitemsweather]
`boolean` · default `true`
The weather pill and its click-to-open forecast popover.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.items.wifi` [#haussillitemswifi]
`boolean` · default `true`
The Wi-Fi status pill.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.color` [#haussilllogocolor]
`null or one of "rosewater", "flamingo", "pink", "mauve", "red", "maroon", "peach", "yellow", "green", "teal", "sky", "sapphire", "blue", "lavender"` · default `null`
The logo's resting colour, by Catppuccin name. `null` (the default)
follows `haus.theme.accent`, which is almost always what you want — the
pill is haus's own mark, so it wearing haus's own accent is the
point.
This is only the RESTING colour. `haus.sill.logo.status` paints over it
while something needs attention, and the hover sweep runs from it and
returns to it.
Example:
```nix
"teal"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.gestures` [#haussilllogogestures]
`boolean` · default `true`
What the logo pill does when clicked:
| gesture | what it opens |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
| left click | the **haus menu** — System Settings, Activity Monitor, Lock Screen, Nix Config, Haus Settings, Rebuild System, Reload SketchyBar |
| ⌘ left click | `haus rebuild`, straight into a floating terminal |
| right click | the full pounce palette (⌘Space), which is what a bare click on this pill used to do |
All three are drawn by **pounce**, so all three need
`haus.pounce.enable` (which the nebelhaus desktop turns on). With pounce
off they are silent no-ops and this option is the switch that says so out
loud — turn it off and the pill stops responding to clicks entirely,
rather than looking like an affordance that does nothing.
The menu's rows are not reimplemented here: each one runs the palette
command of the same name, so fixing one fixes both places. That is the
whole reason the popup dropdown this replaces is gone — it was a second
copy of five of these rows, and (having never been openable at all) a
second copy nobody could check.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.icon` [#haussilllogoicon]
`string` · default `""`
The glyph in the far-left logo pill — the one that was an Apple menu
until it was the nebelhaus cat-ears mark. Any single character your bar
font can draw; the default is Nerd Font's `nf-fa-home` (`U+F015`), a
solid house.
It has to hold up at 28pt with a pill's padding around it, which rules
out more glyphs than you would expect. In particular **`⌂` (`U+2302`),
the hausfold mark itself, is drawn hairline-thin in JetBrains Mono and
does not gain weight at Bold or ExtraBold** — it is in the font, it is
on the list below, and beside the workspace pills it reads as a much
lighter object than everything around it. A taste call, not a bug: if
you want the literal mark, take it and raise `haus.sill.logo.size`.
Six that hold up at bar size, most to least solid:
| glyph | codepoint | what it is |
| ----- | --------- | --------------------------------------------- |
| `` | `U+F015` | `nf-fa-home` — solid house (the default) |
| `` | `U+F46D` | `nf-oct-home` — outlined house at icon weight |
| `` | `U+EB06` | `nf-cod-home` — the same, slightly rounder |
| `⌂` | `U+2302` | the hausfold mark, hairline |
| `` | `U+F302` | `nf-fa-apple` — the logo this pill replaced |
| `` | `U+F313` | `nf-linux-nixos` — the snowflake |
There is deliberately no way to point this at an image file. SketchyBar
draws a `background.image` left-anchored, at a scale you have to
hand-tune per asset, and applies no tint to it — so a picture here can
follow neither `haus.theme.accent` nor the state colours below, and
cannot sweep on hover. haus drew this pill as a PNG for a while and
every one of those was a real limitation of it.
Example:
```nix
"⌂"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.size` [#haussilllogosize]
`positive integer, meaning >0` · default `20`
Point size of the logo glyph. Its own knob rather than the bar's
`FS_ICON`, because the glyphs worth putting here have wildly different
optical sizes: the default solid house wants 20, `⌂` needs 25 before it
stops looking like a typo, and a Nerd Font apple wants 17.
Example:
```nix
25
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.status` [#haussilllogostatus]
`boolean` · default `true`
Let the logo's colour report the health of the machine, so the pill says
something without being clicked:
| colour | meaning |
| -------- | -------------------------------------------------------------------- |
| accent | everything haus runs is up |
| `yellow` | a newer haus is pinned upstream (needs `haus.sill.logo.updateCheck`) |
| `red` | something haus runs is enabled but not running |
Red is the one that matters. It is the same check `haus doctor` opens
with — `nix-daemon`, plus each of AeroSpace / SketchyBar / pounce whose
launchd job exists on this machine — and its whole point is that a
wedged agent is otherwise invisible: the bar keeps drawing the last
frame it painted, so a dead SketchyBar and a quiet one look identical.
All of it is local, costs four `pgrep`s on a five-minute tick, and
makes no network call.
Yellow ranks below red and both outrank the accent, so the pill always
shows the worst thing true about the machine.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.sweep` [#haussilllogosweep]
`boolean` · default `true`
Sweep the logo through the six hausfold accents — mauve, teal, green,
yellow, peach, pink, the order the site runs them (nebelung → holt →
perch → trill → pounce → nebelhaus) — while the pointer is over it,
then settle back.
It is the bar's copy of the mark on hausfold.co, where hovering the `⌂`
turns a conic gradient of those same six through the glyph. SketchyBar
cannot put a gradient inside a glyph, so the sweep IS the gradient: one
colour at a time, animated.
It only runs from the resting accent. A pill sitting at yellow or red
has something to say, and a rainbow running over that is a pill saying
two things at once — so hover does nothing until the state clears.
Leader mode suppresses it for the same reason.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.logo.updateCheck` [#haussilllogoupdatecheck]
`boolean` · default `false`
Add the yellow "a newer haus is available" state to the logo pill. Off
by default because it is the one part of the pill that leaves the
machine: it asks GitHub for haus's current head (the same
`git ls-remote` behind `haus status`) once every half hour, and a bar
that phones home should be something you turned on.
No effect unless `haus.sill.logo.status` is on.
Example:
```nix
true
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.media.artworkTint` [#haussillmediaartworktint]
`boolean` · default `false`
Colour the media pill's glyph from the current cover art instead of from
what kind of thing is playing.
The colour is the cover's average, SNAPPED to the nearest member of the
Nebelung palette — so the pill picks up the mood of a record without ever
drawing a colour that isn't in the theme. Off by default because it
trades a stable meaning (pink is Music, green is Spotify, red is video)
for a colour that changes every three minutes.
Only sources that publish artwork can drive it, which is fewer than you
would think: every Firefox-family browser publishes none at all, and the
pill falls back to the kind colour for those.
Example:
```nix
true
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.media.collapse` [#haussillmediacollapse]
`boolean` · default `false`
Draw the media pill as its glyph alone, and reveal the title only while
the pointer is on it.
Worth having on a MacBook: the bar's centre span is under the notch, so
every character of scrolling track title is rent paid out of the room
the workspace pills and the front-app name need. The pill still hides
itself entirely when nothing is playing — this is about the case where
something is.
Example:
```nix
true
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.media.icons` [#haussillmediaicons]
`attribute set of string` · default `{ }`
Override the media pill's glyph, keyed by bundle id
(`com.spotify.client`) or by KIND — one of `music`, `spotify`,
`podcast`, `video`, `vlc`, `browser.video`, `browser.music`, `other`.
A bundle id wins over a kind.
This exists because of one hard limit: **nothing on the machine can tell
you which site a browser tab is playing.** macOS's now-playing session
carries no URL, window titles only ever name the FOREGROUND tab (the one
playing audio is usually behind), Firefox-family browsers publish no
artwork to shape-check, and the session's pid is the browser's parent
process rather than the tab's. So the pill draws a neutral video glyph
for a browser rather than guessing YouTube and being wrong on Netflix.
If you know that on YOUR machine browser video means YouTube, say so:
haus.sill.media.icons."browser.video" = "";
Example:
```nix
{
"browser.video" = "";
"com.apple.podcasts" = "";
}
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.media.width` [#haussillmediawidth]
`positive integer, meaning >0` · default `32`
How wide the media pill's title is allowed to get, in CHARACTERS — not
pixels. Anything longer is clipped to this and swept past instead, so
this is the knob for how much of the bar the now-playing title may rent.
Narrow it on a MacBook, where the bar's centre span sits under the notch
and every character of title is paid for out of the room the workspace
pills and the front-app name need. `haus.sill.media.collapse` is the
harder version of the same trade: no title at all until you hover.
It is a MAXIMUM, not a fixed size — the pill still shrinks to fit a
short title, so a wide setting costs nothing until something long plays.
Example:
```nix
16
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.sill.position` [#haussillposition]
`one of "top", "bottom", "auto"` · default `"top"`
Where the bar sits. `top` and `bottom` pin it there. `auto` flips it
at runtime — `bottom` whenever an external display is attached (docked
with the lid open, or clamshell), `top` on the built-in display alone —
driven by a `display_change` hook, so the bar moves the moment you dock
or undock, without a rebuild.
The bar's height/pill offsets are tuned for the notch, which only
exists at the top of the built-in display; at `bottom` there's no notch
to tuck under, so `auto` conveniently keeps the notch case (`top`) on
the notched screen and the plain case (`bottom`) on the external.
Example:
```nix
"auto"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
## Launcher [#launcher]
Pounce — the command palette, its daemon, its commands, and every Pounce setting haus exposes.
### haus.pounce [#hauspounce]
The ⌘Space command palette.
#### `haus.pounce.autoQuit.delay` [#hauspounceautoquitdelay]
`integer or floating point number between 0.25 and 3600 (both inclusive)` · default `2`
Seconds to wait after the last window closes before looking again and
quitting. Load-bearing, not politeness: it is what tells "I'm done with
this app" apart from "close this window, open another" — which is what
a browser does when you close its last window and hit ⌘N. Anything open
at the end of the wait, including panels and dialogs the ⌘Tab switcher
wouldn't list, calls the quit off.
Two seconds is the responsive end of that trade. It is deliberately not
enough for a cold IDE reopening a project — that is a case for
haus.pounce.autoQuit.exclude rather than for a delay you would feel on
every app.
Read once, when auto-quit arms — changing it bounces the pounce daemon
on the next rebuild.
Example:
```nix
5
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.autoQuit.enable` [#hauspounceautoquitenable]
`boolean` · default `false`
Quit an app when you close its last window, the way Windows does it.
macOS keeps a windowless app running, so every one of them is a ⌘Q you
forgot; with this on, pounce notices the last window go away and asks
the app to quit.
*Asked*, not killed — it is the same Quit event ⌘Q sends, so an app
with unsaved work puts its sheet up and stays. Nothing here can lose
work that ⌘Q wouldn't. What it CAN do is stop background work you were
keeping a window open for: close Docker Desktop's dashboard and Docker
is asked to quit, which stops your containers. Media players, torrent
clients and chat apps have the same shape — that class of app is what
haus.pounce.autoQuit.exclude is for.
Reads the same window snapshot as the ⌘Tab switcher, so it wants the
same Accessibility grant (set haus.pounce.signingIdentity so it
survives rebuilds) and shares the observers rather than taking its own.
Without the grant it stays off and says so in the log rather than
guessing.
Off by default: this changes when your apps die, which is a thing you
feel, and the muscle memory it suits is not everyone's.
Unlike the rest of pounce's config, the auto-quit settings are read once
— when the daemon arms them — rather than per open. So a rebuild that
touches any of the three restarts the pounce daemon, which haus does
for you; nothing here needs a log-out to land.
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.autoQuit.exclude` [#hauspounceautoquitexclude]
`null or (list of string)` · default `pounce's own list — `\[ "com.apple.finder" ]\`\`
Bundle ids never auto-quit. `null` leaves pounce's own default in
place, which is `[ "com.apple.finder" ]` — Finder is the one app macOS
runs windowless by design, and quitting it blinks the desktop out while
it relaunches.
A list you write **replaces** that default rather than extending it, so
put Finder back in it unless you mean to drop it. `[ ]` really does
mean nothing is excluded.
Read a bundle id off any running app with
`osascript -e 'id of app "Notes"'`.
Read once, when auto-quit arms — adding an app here bounces the pounce
daemon on the next rebuild, so the app stops being quit immediately
rather than at the next log-in.
Example:
```nix
[
"com.apple.finder"
"com.docker.docker"
"com.spotify.client"
]
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.enable` [#hauspounceenable]
`boolean` · default `false`
The pounce command palette daemon (⌘Space) + the palette commands haus ships.
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.followSystemAppearance` [#hauspouncefollowsystemappearance]
`boolean` · default `true`
Let the palette follow macOS Light/Dark Mode instead of pinning one
polarity: pounce gets the nebelung variant AND its latte counterpart at
your haus.theme.contrast, as its `theme`/`themeLight` pair, and
picks between them per open (no rebuild, no daemon restart).
Honest scope: this makes pounce the one themed tool that does NOT follow
haus.theme.flavor — a flavor pin is a *palette* choice, and asking
to follow the system says the polarity is macOS's call. The contrast
axis still applies to both halves. Every other themed tool keeps
whatever flavor pins.
false pins pounce to the flavor like every other port, which is exactly
what it did before this option existed.
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.items` [#hauspounceitems]
`attribute set of (submodule)` · default `{ }`
Per-item palette settings, keyed by the item's own address. One entry is
one row of the palette: hide it, give it a search shorthand, give it a key.
"cmd:\" a command, by script name without .sh
"app\:/Applications/Foo.app" an application, by path
"mode:\" a built-in window — launcher, clipboard,
emoji, screenshots, camera, filesearch
Those keys are pounce's own address space (the same strings its frecency
store and `pounce run` use), so a key written here is also what you'd type
to invoke the thing from a script or another tool's binding.
Hotkeys can be a single chord ("opt+e") or a LEADER SEQUENCE — steps
separated by spaces, modifiers by "+", the notation Emacs and VS Code use:
hotkey = "opt+space e"; # ⌥Space, then E
hotkey = \[ "cmd+k" "cmd+c" ]; # the same thing, step by step
The modifier-only laptop Fn/Globe key is the one special single-step
value: hotkey = "fn". It needs Pounce's Accessibility grant, unlike a
Carbon chord or leader sequence, and fires only when Fn is tapped alone.
haus uses it for mode:emoji by default; set that item's hotkey to
null to leave the Globe key to macOS.
Sequences are worth knowing about on a tiling desktop: they open a namespace
that structurally can't collide with the ⌥/⌘ chords prowl already claims,
and they need no Accessibility grant (pounce grabs the second step as an
ordinary global hotkey for a couple of seconds rather than tapping events).
Two things this checks at build time, because both fail SILENTLY at
runtime: a key that names no real item shape (a "mode:" typo binds
nothing at all), and a chord already claimed by haus.keys.palette,
haus.keys.leader, or a terminal binding (whoever registers first
wins, and it isn't always the same one). What it can't check is whether
`cmd:` names a command that exists — command scripts are discovered
at runtime, so pounce warns about that itself when the daemon starts, and
`pounce doctor` lists any binding that failed to arm.
Example:
```nix
{
"app:/Applications/Ghostty.app" = {
hotkey = "opt+t";
};
"cmd:brew-services" = {
listed = false;
};
"cmd:emoji" = {
alias = "emo";
hotkey = "opt+e";
};
"mode:clipboard" = {
hotkey = "cmd+shift+v";
};
}
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.items..alias` [#hauspounceitemsnamealias]
`null or string` · default `null`
A search shorthand, matched at a bonus over the item's real name —
so "emo" can find the Emoji Picker without renaming it.
Example:
```nix
"emo"
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.items..caption` [#hauspounceitemsnamecaption]
`null or string` · default `null`
How this item reads on the cheatsheet page that lists your item
hotkeys (⌘Space then ⇥, or the leader's `/`). Only used when the
item has a `hotkey` — a row without a key has nothing to teach.
Defaults to a name derived from the key, which is right often
enough to leave alone: `mode:clipboard` becomes "Clipboard
history", `app:/Applications/Ghostty.app` becomes "Ghostty", and
`cmd:brew-services` becomes "Brew services". Set this when the
derived name isn't what the palette actually calls the row —
haus can't read a command's own `# pounce: name` header at
evaluation time, so that one is a guess.
Example:
```nix
"Clipboard history"
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.items..hotkey` [#hauspounceitemsnamehotkey]
`null or string or list of string` · default `null`
A global chord, or a leader sequence, that invokes this item
directly without opening the palette first. Modifier names follow
pounce's spelling: cmd/command/super/meta · opt/option/alt ·
ctrl/control · shift.
Whether the KEY name is one pounce can bind is not checked here
(that vocabulary lives in the app); a chord it can't register is
reported by `pounce doctor` rather than silently dropped.
`fn` is the modifier-only exception: it uses Pounce's
Accessibility-gated event tap, fires only on a lone tap, and
suppresses macOS's stock Globe action while armed.
Example:
```nix
"opt+space e"
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.items..listed` [#hauspounceitemsnamelisted]
`boolean` · default `true`
Whether the item appears in the palette's list.
Named `listed` rather than `enable` because that is precisely what
it does: false removes the ROW, and a `hotkey` on the same item
keeps working. It's how you hide a command you only ever want to
reach by key — or clear the launcher of tools someone else on this
Mac has no use for, which is the closest thing to a "pack" the
surface has today. (It writes pounce's own `enabled` key.)
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.scale` [#hauspouncescale]
`integer or floating point number between 0.8 and 2.0 (both inclusive)` · default `haus.ui.scale, held inside pounce's 0.8-2.0`
How big the palette is drawn. Multiplies every size in pounce's UI — the
launcher's rows, header, icons and action bar, and the panels behind it:
the emoji grid, clipboard history, recent screenshots, camera peek, Find
Files, the cheatsheet and the window switcher.
Follows haus.ui.scale by default, so you rarely set this directly.
It exists as its own option for the case where the palette wants a
different size from the rest of haus — the launcher is read at arm's
length for a second, not lived in like the terminal.
pounce's own range is narrower than ui.scale's, so a machine at
`ui.scale = 2.5` gets a palette at 2.0 rather than an evaluation error.
Two things adapt on their own, which is why one number is enough: the
launcher shows fewer rows when the scaled rows stop fitting on screen, and
every panel's width is held inside the visible frame. That matters most
alongside `haus.displays..uiScale` — a larger-text display mode
and a larger palette multiply, and the palette is the one that would
otherwise run off the edge.
Example:
```nix
1.4
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.signingIdentity` [#hauspouncesigningidentity]
`string` · default `""`
A code-signing identity in your login keychain — either its SHA-1 or
(preferred) its full common name. The pounce daemon is re-signed with
it so a macOS Accessibility (TCC) grant survives rebuilds. List yours:
security find-identity -v -p codesigning
Prefer a "Developer ID Application" identity passed BY NAME (e.g.
"Developer ID Application: Jane Doe (TEAMID)"): its designated
requirement anchors on the stable team OU, so the grant survives even
a certificate renewal (the renewed cert keeps the same name/team but
gets a new SHA — a hardcoded SHA would silently fall back to unsigned).
This is also the identity the Homebrew build is signed with, so both
install paths share one identity. An "Apple Development" cert works too
but expires yearly and pins the specific cert, so it's less durable.
Changing this once invalidates the existing grant (the requirement
changes) — re-approve pounce in Accessibility a single time after.
Leave empty to run pounce unsigned (the palette works, but auto-paste
and other Accessibility-gated features stay off).
Example:
```nix
"Developer ID Application: Jane Doe (ABCDE12345)"
```
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.windowMode` [#hauspouncewindowmode]
`one of "default", "compact"` · default `"compact"`
The palette's proportions. `compact` is narrower with tighter rows and
keeps its list hidden until you type — haus's tuned look, and what it
shipped before this option existed. `default` is pounce's roomier layout,
which shows the top results the moment it opens.
This is shape, not size: how BIG the palette is drawn is
haus.pounce.scale. The two compose — a compact palette at scale 1.4
is still the compact layout, just readable from further away.
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
#### `haus.pounce.windowSwitcher` [#hauspouncewindowswitcher]
`boolean` · default `true`
Replace the stock ⌘Tab app switcher with pounce's MRU *window* switcher:
tap ⌘⇥ to toggle to the last window (across workspaces), hold ⌘ and keep
tapping ⇥ to walk older ones, type while holding to fuzzy-filter
(frecency-ranked). Rows are gathered by AeroSpace workspace under a
header each, and focusing goes through `aerospace focus --window-id` so
a window parked on another workspace surfaces correctly.
Because prowl is tiling here, a bare tap deliberately looks past the
workspace you're on and takes the most recent window on a different
one — with two panes tiled side by side the most recent window is one
you're already looking at, so landing there wouldn't be a switch.
Moving between visible tiles stays windowNav's focus keys; the skipped
siblings are still the rows just below you in the list.
Needs the daemon to hold an Accessibility grant — in practice, set
haus.pounce.signingIdentity so the grant survives rebuilds. Without
the grant the event tap can't install and stock ⌘Tab keeps working, so
this default is safe on a fresh, not-yet-granted install. false leaves
⌘Tab native even when the grant is there.
Declared in
[`modules/pounce/options.nix`](https://github.com/hausfold/haus/blob/main/modules/pounce/options.nix)
.
## Shelf [#shelf]
Perch — the file shelf that grows out of the notch to catch what you drag at it.
### haus.perch [#hausperch]
The notch file shelf.
#### `haus.perch.enable` [#hausperchenable]
`boolean` · default `false`
The perch notch file shelf, installed via the perch flake (copied to /Applications).
Declared in
[`modules/perch/options.nix`](https://github.com/hausfold/haus/blob/main/modules/perch/options.nix)
.
#### `haus.perch.followSystemAppearance` [#hausperchfollowsystemappearance]
`boolean` · default `true`
Let the shelf's palette follow macOS Light/Dark Mode instead of pinning
one polarity: perch gets the nebelung variant AND its latte counterpart
at your haus.theme.contrast, and picks between them itself — no
rebuild, no relaunch.
Same honest scope as the pounce option of the same name: with
this on, perch does NOT follow haus.theme.flavor, because asking to
follow the system says the polarity is macOS's call. The contrast axis
still applies to both halves. Set it false to pin the shelf to
theme.flavor like every other themed tool.
Perch has no theme picker of its own — the shelf is a five-second
surface with nowhere to put one — so this is the only word on its
colors.
Declared in
[`modules/perch/options.nix`](https://github.com/hausfold/haus/blob/main/modules/perch/options.nix)
.
## Focus [#focus]
One quiet switch: Do Not Disturb, an optional status somewhere else, and your own hooks on both edges.
### haus.hush [#haushush]
One quiet switch: Do Not Disturb, optional Slack status, and your hooks.
#### `haus.hush.enable` [#haushushenable]
`boolean` · default `false`
The hush room: one quiet switch — bar pill, palette command, and a
`hush` CLI — that turns macOS Do Not Disturb on/off (via the
declaratively-bound symbolic hotkey 175, pressed synthetically),
optionally sets your Slack status, and runs your hooks.
Honest scope: hush flips the built-in Do Not Disturb, not named Focus
modes, and it doesn't manage which apps break through — curate that
once in System Settings. The keypress needs an Accessibility grant on
whatever app invokes hush (palette runs inherit pounce's; grant
sketchybar once for the pill). `hush doctor` walks the one-time steps.
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
#### `haus.hush.hooks` [#haushushhooks]
`list of (absolute path or string)` · default `[ ]`
Extra scripts run on every hush/unhush, each called with a single
argument "on" or "off". Paths are copied into the store; strings are
run as-is (so $HOME paths work). Failures are logged, never fatal —
a broken hook can't wedge the toggle.
Example:
```nix
[ ./onair-light.sh "/Users/ada/bin/pause-music" ]
```
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
#### `haus.hush.slack.enable` [#haushushslackenable]
`boolean` · default `false`
Also set a Slack status and snooze Slack notifications (all devices,
phone included) while hushed. Off by default: it needs a personal
Slack user token (scopes users.profile:write + dnd:write) provided
via tokenCommand. The previous status is saved and restored on
unhush.
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
#### `haus.hush.slack.snooze` [#haushushslacksnooze]
`boolean` · default `true`
Also pause Slack's own notifications (dnd.setSnooze) while hushed —
this is what silences the phone. Ended on unhush; capped at 24h as
a failsafe if you forget.
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
#### `haus.hush.slack.statusEmoji` [#haushushslackstatusemoji]
`string` · default `":no_bell:"`
Slack status emoji while hushed.
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
#### `haus.hush.slack.statusText` [#haushushslackstatustext]
`string` · default `"heads down"`
Slack status text while hushed.
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
#### `haus.hush.slack.tokenCommand` [#haushushslacktokencommand]
`string` · default `""`
Shell command that prints the Slack user token (xoxp-…) to stdout.
Keychain-first so no secret ever lands in the store or a dotfile:
security add-generic-password -s hush-slack -a $USER -w 'xoxp-…'
Example:
```nix
"security find-generic-password -s hush-slack -w"
```
Declared in
[`modules/hush/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hush/options.nix)
.
## AI [#ai]
Coding agents: which clients this machine installs, the worktree lifecycle around them, and the instructions and `haus` skill every client reads.
### haus.ai [#hausai]
The AI room: whether this machine runs coding agents at all, which clients it installs, which one the agent keybinding spawns, and the two files haus ships into every one of their homes — your instructions, and the `haus` skill. Spelled `haus.agents.*` before 2026-08-13, with the switch under `haus.developer.agents`; both are gone rather than aliased.
#### `haus.ai.clients` [#hausaiclients]
`list of (one of "claude", "codex", "opencode")` · default `[ ]`
Which coding-agent clients to install. `claude` is Claude Code, `codex`
is OpenAI Codex, `opencode` is OpenCode. The ⌘A terminal binding starts
whichever one `ai.default` names — Claude Code through its own
`--worktree` hook, the others through `holt new`.
A list rather than one bool per client, matching `developer.languages`
— a fourth client later doesn't change this option's shape.
This is the option that makes `ai.default` honest. Naming a client
you have not installed used to fail *at spawn time*, inside the pane,
after the worktree already existed: a flash of
`codex is unavailable`, and litter to reap. `ai.default` must now
be a member of this list, so the same mistake fails the rebuild
instead, with both values named.
Override the package for a client the usual Nix way — an overlay on
`claude-code`, `codex` or `opencode` — rather than dropping the client
here and installing your own copy alongside; two derivations shipping
the same `bin/` name collide in one profile.
Ignored entirely when `ai.enable` is off — see `haus._ai.clients`, the
resolved list every room actually installs from. Before step 4 this was
an assertion instead ("clients are set but the room is off"), which was
right while the list defaulted from the room's own switch and wrong
afterwards: a desktop names the clients, so a host turning the room off
would have had to blank the desktop's list as well to get a rebuild at
all. One switch now removes the room, which is what "clean removal when
disabled" means.
Example:
```nix
[
"claude"
"codex"
]
```
Declared in
[`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)
.
#### `haus.ai.default` [#hausaidefault]
`one of "claude", "codex", "opencode"` · default `"claude"`
The coding agent started by Pounce's **Spawn Agent** command, by the
⌘A / Super-a zellij binds and the `c` shell alias, and used to reopen
worktrees with no client recorded yet. Each spawned worktree records its
own client, so changing this affects new work but never reopens an
existing Codex or OpenCode task in Claude.
Must be one of `ai.clients` — see there.
Only `claude` can make its own worktree (its native `--worktree` flag,
which fires `holt hook create`); for `codex` and `opencode` ⌘A runs
`holt new` instead, producing the same checkout, branch and registry
entry from the outside. Resuming follows the client too: `codex` reopens
its cwd-filtered `codex resume` picker, `opencode` continues its latest
session for that cwd. All three share one `holt` branch/parking/reap
lifecycle, and all three light up the `agents` bar pill and the zellij
tab-bar badge — the opencode plugin and the codex hooks are written for
you; only Claude Code's stay yours to wire, because Claude owns its own
settings.json (see `haus.sill.items.agents`).
Example:
```nix
"codex"
```
Declared in
[`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)
.
#### `haus.ai.enable` [#hausaienable]
`boolean` · default `false`
The AI room: coding-agent *tooling*. `holt` (agent worktrees),
`agent-state` (the pane-status writer behind the `agents` bar pill and
the zellij tab badge), the agent-worktree statusline, and the client
config hearth writes (Claude Code's settings.json keys, opencode's
agent-state plugin). Which clients get installed is `ai.clients`.
On, this room brings its clients, `holt` and the lifecycle wiring on its
own. What it adds to OTHER rooms it adds only when they are present: the
⌘A terminal binds and the `c` alias arrive with the terminal, the
`agents` pill with the bar, the agent commands with the launcher. None
of those rooms is switched on by turning this one on.
Off is right for any machine not running coding agents — it's a large
surface a non-developer never sees. The neutral default installs no
clients; a desktop that selects this room names both `ai.clients` and
`ai.default`.
Was `haus.developer.agents.enable`, and the rest of this namespace was
`haus.agents.*`, until 2026-08-13. Neither spelling is aliased — see
modules/moved.nix for why.
Declared in
[`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)
.
#### `haus.ai.instructions` [#hausaiinstructions]
`strings concatenated with "\n"` · default `""`
Your always-on, cross-project operating context — the "instructions"
slot every client has under a different name. Written once per client
in `ai.clients`, to the path that client actually reads:
`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`,
`~/.config/opencode/AGENTS.md`.
Write it client-neutrally: the same text reaches whichever agent the ⌘A
pane spawns, so a line about a Claude-only skill or file path is noise
to the other two. When set, haus prepends two short sections of its
own — a note that the file is generated and where to actually edit it
(with THAT client's path), and the `holt` worktree etiquette, since
haus ships `holt` and that rule is what keeps it working — then your
text.
Empty (the default) writes nothing at all, for any client, so a
hand-managed instructions file is never clobbered just to inject
haus's note. If you set it and one of those paths already holds a file
you wrote by hand, home-manager moves yours aside as `.backup`
rather than refusing — quiet, so check for one before the first rebuild
after setting this.
With `ai.clients` empty (a machine haus installs no client on)
every known client's path is written instead of none: the list being
empty means haus installs none, not that no agent runs here.
Example:
```nix
''
# How I work
Ship small, verified changes; ask before anything hard to reverse…
''
```
Declared in
[`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)
.
#### `haus.ai.skill` [#hausaiskill]
`boolean` · default `true`
Install the `haus` skill for every client in `ai.clients`, so an
agent asked to "install Slack" or "make everything bigger" edits your
host file and runs `haus rebuild` instead of guessing at dotfiles and
`brew install`.
One copy per client, in the directory that client scans:
`~/.claude/skills/haus`, `~/.codex/skills/haus`,
`~/.config/opencode/skills/haus`. OpenCode also scans `~/.claude/skills`
for Claude Code compatibility, and prefers its own copy when both
exist — so a machine running both clients sees the skill once, not
twice.
The skill's option reference is GENERATED from the haus revision this
machine is pinned to, so it can only ever describe options that
actually exist here — and it is regenerated by `haus update`. It also
carries this host's current state (which rooms are on, where the host
file is) and a starter AGENTS.md + CLAUDE.md pair for your config repo —
the rules in the first, a one-line import in the second, so a session
opened there is oriented whichever client it runs.
Unrelated to the clients' own settings, which follow
`haus.ai.enable`. This is a plain file drop: with
`ai.clients` empty — a machine haus installs no client on, which
can still have one from npm or Homebrew — every known client's directory
gets a copy rather than none. Set false to leave every client's skills
directory alone.
Declared in
[`modules/ai/options.nix`](https://github.com/hausfold/haus/blob/main/modules/ai/options.nix)
.
## Text expansion [#text-expansion]
Snippets, and the engine that types them out for you.
### haus.snippets [#haussnippets]
Text expansion via espanso.
#### `haus.snippets.enable` [#haussnippetsenable]
`boolean` · default `false`
Text expansion via espanso: type a short trigger (say "@@") and it's
replaced inline with a longer string (your email), in any app —
browsers, Messages, and the terminal. espanso injects keystrokes, so
it works where macOS's own text replacement doesn't (terminals,
many Electron apps).
Off by default: it installs the Espanso.app cask and needs a one-time
macOS Accessibility grant (System Settings → Privacy & Security →
Accessibility → enable Espanso) the first time it runs. haus runs
the SIGNED app bundle rather than a nix-store binary on purpose, so
that grant is keyed to a stable identity and survives reboots and
nixpkgs bumps — you grant it once, not on every rebuild (and so the
espanso troubleshooting window stops popping up at login, since that
window only ever meant "the grant went missing").
Declared in
[`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)
.
#### `haus.snippets.matches` [#haussnippetsmatches]
`list of (submodule)` · default `[ ]`
The expansion table — one \{ trigger; replace; } per snippet, written
to \~/.config/espanso/match/default.yml. Only espanso's plain
trigger→replace form is exposed here; for dynamic matches (dates,
shell output, forms) drop a hand-written .yml alongside it in
\~/.config/espanso/match/ — espanso loads every file in that dir.
Example:
```nix
[
{ trigger = "@@"; replace = "ada@example.com"; }
{ trigger = "##"; replace = "+1 555 0100"; }
]
```
Declared in
[`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)
.
#### `haus.snippets.matches.*.replace` [#haussnippetsmatchesreplace]
`string` · no default
What it expands to.
Example:
```nix
"ada@example.com"
```
Declared in
[`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)
.
#### `haus.snippets.matches.*.trigger` [#haussnippetsmatchestrigger]
`string` · no default
What you type.
Example:
```nix
"@@"
```
Declared in
[`modules/snippets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/snippets/options.nix)
.
## Security [#security]
Touch ID for sudo, lock behaviour, the firewall, and where secret values come from.
### haus.lock [#hauslock]
Whether waking this Mac needs a password, and how long the grace period is. Worth setting on any laptop that leaves the house.
#### `haus.lock.requirePassword` [#hauslockrequirepassword]
`null or boolean` · default `null`
Require a password to wake this Mac from sleep or the screen saver.
null (the default) leaves macOS's own choice alone.
The one setting in this group worth turning on for ANY shared or
portable machine — a family Mac, a laptop that leaves the house.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.lock.requirePasswordDelay` [#hauslockrequirepassworddelay]
`null or (unsigned integer, meaning >=0)` · default `null`
Seconds to wait after sleep/screen-saver starts before
`requirePassword` actually locks the screen — macOS's "grace period".
null (the default) leaves macOS's own choice alone.
0 locks instantly. Has no effect while `requirePassword` is null or
false.
Example:
```nix
5
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.security [#haussecurity]
Security posture: the built-in application firewall and how strict it is. Off on a fresh Mac; the setting to turn on for a laptop that joins networks you don't own.
#### `haus.security.firewall.allowSigned` [#haussecurityfirewallallowsigned]
`null or boolean` · default `null`
Let built-in, Apple-signed software receive incoming connections
without asking. null (the default) leaves macOS's own choice alone.
Has no effect while `enable` is null or false.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.security.firewall.allowSignedApp` [#haussecurityfirewallallowsignedapp]
`null or boolean` · default `null`
Let downloaded, signed third-party software receive incoming
connections without asking. null (the default) leaves macOS's own
choice alone. Has no effect while `enable` is null or false.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.security.firewall.blockAllIncoming` [#haussecurityfirewallblockallincoming]
`null or boolean` · default `null`
Block ALL incoming connections, including ones apps ask for (AirDrop,
screen sharing, a dev server on your LAN). null (the default) leaves
macOS's own choice alone. Has no effect while `enable` is null or
false.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.security.firewall.enable` [#haussecurityfirewallenable]
`null or boolean` · default `null`
The built-in application firewall. null (the default) leaves
macOS's own choice alone (off, on a fresh install).
The "public Wi-Fi" setting: worth true for a laptop that leaves
home, closer to unnecessary for a desktop that never does.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.security.firewall.stealthMode` [#haussecurityfirewallstealthmode]
`null or boolean` · default `null`
Don't respond to network probes (ping, closed-port connection
attempts) at all, instead of replying "connection refused". null
(the default) leaves macOS's own choice alone. Has no effect while
`enable` is null or false.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.collar [#hauscollar]
Touch ID for sudo — including inside a terminal multiplexer — and the passwordless-rebuild rule.
#### `haus.collar.enable` [#hauscollarenable]
`boolean` · default `false`
The collar room: Touch ID for `sudo`, with `reattach` — the PAM shim
that keeps the prompt working when sudo runs inside a terminal
multiplexer (tmux/zellij/screen), where it otherwise beachballs.
Off means macOS's stock password prompt everywhere, including for the
rebuild below. Nothing else in haus depends on it.
Declared in
[`modules/collar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/collar/options.nix)
.
#### `haus.collar.passwordlessRebuild` [#hauscollarpasswordlessrebuild]
`boolean` · default `false`
Exempt system activation from authenticating at all: a sudoers rule
granting NOPASSWD to `darwin-rebuild` and `haus-activate` at their
stable /run/current-system paths. This is what makes `haus rebuild`,
`haus rollback` and `bench try switch` a single uninterrupted command
rather than one that stops for a fingerprint you already gave.
Honest scope: this is a real root grant, and both commands take a path
or flake ref you choose — so it means "anything I can build, I can
activate as root, unprompted". That is the whole point (you already
authenticated to build it), but on a shared or managed machine it's the
knob to turn off. With it off, activation prompts via Touch ID (or a
password when `enable` is false) and nothing else changes.
Declared in
[`modules/collar/options.nix`](https://github.com/hausfold/haus/blob/main/modules/collar/options.nix)
.
### haus.secrets [#haussecrets]
Where secret values come from on this machine.
#### `haus.secrets.provider` [#haussecretsprovider]
`null or string` · default `"keyring"`
The secretspec provider that supplies secret VALUES on this machine.
The secrets room writes it to \~/.config/secretspec/config.toml as the
default provider, so `secretspec run / check / set` work without
flags. Any provider string secretspec accepts, URIs included:
"keyring" (macOS login keychain — local, no accounts), "onepassword",
"bws" (Bitwarden Secrets Manager), "gcsm" (Google Cloud Secret
Manager), "awssm" (AWS Secrets Manager), "vault", "pass",
"protonpass", "lastpass", "dotenv", "env", or a scoped URI like
"onepassword://account\@vault".
WHICH secrets exist is not declared here — that's each project's
committed secretspec.toml. Cloud providers authenticate with their own
credentials, configured outside Nix (e.g. `gcloud auth
application-default login` for gcsm); that login is the one manual
step on a new Mac. null skips writing the config file entirely — run
`secretspec config init` yourself.
Example:
```nix
"gcsm"
```
Declared in
[`modules/secrets/options.nix`](https://github.com/hausfold/haus/blob/main/modules/secrets/options.nix)
.
## Shared surfaces [#shared-surfaces]
Surfaces more than one room reads: the app roster, the workspaces, the keys haus owns, the interface scale, the first-run tour. They belong to no single room because moving one into a room would make the others depend on it.
### haus.roster [#hausroster]
One list of everything this machine has — apps, fonts, command-line tools. Each entry drives its launcher key, cheatsheet row, and installs it from whichever source it names: a Homebrew cask or formula, a Nixpkgs package, or the Mac App Store.
#### `haus.roster` [#hausroster-1]
`attribute set of (submodule)` · default `{ }`
The one list of things this machine has, keyed by a stable id. It is
the canonical, composable source for AeroSpace launcher keys, the
SketchyBar pills, the pounce cheatsheet, Nebelung theme ports — and
for the install itself, from any of four sources (`cask`, `brew`,
`package`, `appStoreId`).
Every field except the id is optional, and WHICH fields you set is
what the entry means. Set `key` and it joins the launcher; set none
of the launcher/workspace/install fields and it's install-only —
which is how a font or a command-line tool lives in the same list as
Slack instead of in a second one beside it. haus's own
`homebrew.casks` / `home.packages` still work and still merge; you
just shouldn't need them for an app.
Which WORKSPACE an app owns is not a field here — it's
`haus.workspaces..apps` naming this entry's id, so one
workspace can hold several apps (a "comms" workspace with Slack,
Mail and Messages) instead of baking "one app, one workspace" into
this schema. See that option.
Attribute-set entries merge across Nix modules, so a host, an imported
file, and pounce's "Install App" command can each contribute one app
without parsing or replacing a monolithic list. Set an entry's enable
field to false to remove it, or override individual fields by app id.
Example:
```nix
{
# Launcher app: leader s. Own workspace + pill come from putting
# "slack" in a haus.workspaces entry's `apps` (see below).
slack = {
key = "s";
name = "Slack";
appId = "com.tinyspeck.slackmacgap";
cask = "slack";
};
# Install-only: no key, so no leader binding and no pill.
framer = { cask = "framer"; };
orbstack = { package = pkgs.orbstack; };
biome = { package = pkgs.biome; scope = "system"; };
ical-buddy = { brew = "ical-buddy"; };
xcode = { name = "Xcode"; appStoreId = 497799835; };
}
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..appId` [#hausrosternameappid]
`null or string` · default `null`
Bundle id, used for the AeroSpace `on-window-detected` auto-assign
rule (when this app is a member of a `haus.workspaces` entry),
the `float` rule below, and the wake-time re-sort. null skips both
— the app still launches, it just isn't herded anywhere or floated.
Find one with `osascript -e 'id of app "…"'`.
Example:
```nix
"com.tinyspeck.slackmacgap"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..appStoreId` [#hausrosternameappstoreid]
`null or signed integer` · default `null`
Mac App Store numeric app id (the digits in its store URL), so an
App Store app is declared in the same roster as everything else
rather than in a comment.
Recording it is always safe; INSTALLING from it is opt-in via
`haus.appStore.install`, because the App Store is the one
source that can't be fully automated: `mas` has no sign-in
command, and it cannot buy a paid app for the first time. Free
apps it can fetch; paid ones you purchase once in App Store.app
and every machine afterwards can install them.
Example:
```nix
497799835
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..brew` [#hausrosternamebrew]
`null or string` · default `null`
Homebrew FORMULA that installs this entry, appended to
homebrew\.brews. For the command-line half of the roster — a tool
with no .app bundle, which usually means `key`, `name` and
`workspace` are all null.
Example:
```nix
"ical-buddy"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..cask` [#hausrosternamecask]
`null or string` · default `null`
Homebrew cask that installs this app. When set, it's appended to
homebrew\.casks so declaring the app also installs it. null means
"already present / installed some other way" (e.g. Safari, Music).
Example:
```nix
"slack"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..enable` [#hausrosternameenable]
`boolean` · default `true`
Whether this app participates in the shared launcher roster.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..float` [#hausrosternamefloat]
`boolean` · default `false`
Always float this app's windows instead of tiling them — an
AeroSpace `on-window-detected` rule generated from `appId`
(`run = 'layout floating'`). Right for a picker/dialog/status
window that would otherwise reflow the whole workspace every time
it opens (FaceTime, Trill's Settings/Inbox), not for something you
work inside. Requires `appId`; ignored (with a warning) without it.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..installedBy` [#hausrosternameinstalledby]
`null or string` · default `null`
The nebelhaus module that puts this app on disk, when none of the
four sources above describes it: pounce and perch copy a
notarized bundle into /Applications from their own activation
step, which is neither a cask nor a package you can list.
Set BY haus, not by you. It exists so the roster can still
answer "who installed this?" for those apps — without it, a host
adding a leader key for Perch had to KNOW haus already ships
it, leave every source field null, and leave a comment explaining
the hole. This is that comment, as data.
Example:
```nix
"haus.perch"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..key` [#hausrosternamekey]
`null or string` · default `null`
The leader letter for this app: tap Caps Lock then this key to
launch/focus it. Must be unique across the roster, and not one of
launch mode's own: `v` `e` `z` `,` `` ` `` `-` `=` `/` `1`-`4` `esc`
and the arrows are taken, and a rebuild refuses them.
null (the default) means the entry is INSTALL-ONLY: it still
brings its cask/formula/package, but claims no leader key, no
cheatsheet row, and no launch-mode bubble. That is what lets one
roster hold both the apps you reach for by keyboard and the ones
you just want on the machine (and fonts, and CLI tools).
Example:
```nix
"s"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..label` [#hausrosternamelabel]
`null or string` · default `null`
Cheatsheet caption for the leader key. null uses name.
Example:
```nix
"Slack"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..name` [#hausrosternamename]
`null or string` · default `null`
macOS application name, as passed to `open -a`. Required when
`key` is set (the launcher has nothing to open otherwise);
null is right for an install-only entry — a font, a CLI tool, or
an app you launch some other way.
Example:
```nix
"Slack"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..order` [#hausrosternameorder]
`signed integer` · default `1000`
Roster order; lower values appear first. Ties are sorted by app id.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..package` [#hausrosternamepackage]
`null or package` · default `null`
Nixpkgs package that installs this entry. Where it lands is
`scope`'s call.
A shared desktop or app pack can't set this one — it needs `pkgs`, and
a data-only desktop has no arguments. Use `packageName` there.
Example:
```nix
pkgs.orbstack
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..packageName` [#hausrosternamepackagename]
`null or string` · default `null`
The same source as `package`, NAMED rather than evaluated: an
attribute path into nixpkgs, so "orbstack" means `pkgs.orbstack` and
"python3Packages.black" means what it says. `scope` applies to it
identically.
This is the source a shared app pack can use — see
`haus.apps.packs`, and `modules/apps/packs/writing.nix` for one.
Without it a pack could install from Homebrew and the App Store but
never from Nixpkgs, because reaching `pkgs` is exactly what the
data-only format forbids — the one gap in the four sources.
Set this or `package`, never both; and it counts as a source like any
other, so pairing it with `cask` is the same mistake as pairing
`cask` with `brew`.
Example:
```nix
"orbstack"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..scope` [#hausrosternamescope]
`one of "user", "system"` · default `"user"`
Which profile `package` installs into.
* "user" (default): home-manager's `home.packages`. Right for
anything you run as yourself — apps, editors, CLI tools.
* "system": nix-darwin's `environment.systemPackages`. Installed
once for the whole machine, so it's on PATH for root, for
non-login shells, and for launchd jobs — which is what a tool
invoked by a daemon, a `sudo` workflow, or an activation script
actually needs. (It is about REACH, not about the package
needing elevated privileges to install: `darwin-rebuild` runs
under sudo either way.)
Ignored when `package` is null — Homebrew has no such split.
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.roster..titleRegex` [#hausrosternametitleregex]
`null or string` · default `null`
Scope `float` to windows of this app whose title matches this
regex (AeroSpace's `window-title-regex-substring`), instead of
every window the app opens. null (default) floats all of them.
Some apps' windows report their title only AFTER AeroSpace has
already detected and tiled them once (a race, not a bug this
option can fix) — Ghostty is the known case, which is why
haus's own Ghostty float rule is hand-written in aerospace.toml
rather than generated from the roster. If a title rule flaps,
that race is almost certainly why. Ignored when `float` is false.
Example:
```nix
"^Picture in Picture$"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
### haus.workspaces [#hausworkspaces]
The named AeroSpace workspaces this machine declares, and which roster apps live on each. A workspace, not an app, owns its bar pill and leader throw — so several apps (a whole "comms" role) can share one.
#### `haus.workspaces` [#hausworkspaces-1]
`attribute set of (submodule)` · default `{ }`
AeroSpace workspaces this machine names on purpose, keyed by the
workspace id AeroSpace itself will use (any string it accepts as a
workspace name — a single letter like `T`, or a word like `comms`).
First-class rather than a field on an app: an app can only ever own
one workspace if the field lives on the app, which makes a role
workspace ("communication" = Mail + Slack + Messages) or a project
workspace literally unrepresentable. Here, a workspace lists its own
members instead.
The four fixed numbered workspaces (1-4, leader/⇧+digit) are not
part of this option — they always exist, independent of what any
app claims. This option is for the NAMED workspaces app windows get
herded onto.
An entry with no `key` and no `apps` does nothing (a warning says
so); one with `apps` but no `key` still gets a persistent workspace,
a pill (with `icon`) and auto-herds its member windows, it just has
no dedicated leader throw.
Example:
```nix
{
# Role workspace: three apps, one pill, one throw key.
comms = {
key = "c";
icon = ":slack:";
apps = [ "slack" "mail" "messages" ];
};
# Single-app workspace: the common case, one entry each.
T = { key = "t"; icon = ":ghostty:"; apps = [ "ghostty" ]; };
}
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.workspaces..apps` [#hausworkspacesnameapps]
`list of string` · default `[ ]`
`haus.roster` app ids that live on this workspace: each
one's window auto-moves here (via its `appId`), opening any of
them from the leader lands you here, and this workspace's `key`
throw (above) sends the focused window here regardless of which
member app owns it. An app id may belong to at most one
workspace.
A plain list, not wrapped in `lib.mkDefault` even where haus
itself contributes to it (ghostty → workspace `T`, say) — list
options MERGE across modules at equal priority but a `mkDefault`
list is dropped whole rather than merged the moment anything else
defines the same option, so a host adding a second app to `T`
would silently lose ghostty's membership if haus's own
contribution used `mkDefault` here. Override a single membership
by dropping the app's id from your own list instead.
Example:
```nix
[
"slack"
"mail"
"messages"
]
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.workspaces..icon` [#hausworkspacesnameicon]
`null or string` · default `null`
The SketchyBar workspace-pill glyph. A sketchybar-app-font
ligature like ":slack:" renders a logo; any other string is
drawn in the bar's Nerd Font. null falls back to the workspace's
own id.
Example:
```nix
":slack:"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.workspaces..key` [#hausworkspacesnamekey]
`null or string` · default `null`
Leader then ⇧\ throws the focused window to this workspace
and follows it there (AeroSpace's `move-node-to-workspace --focus-follows-window`). There is no bare \ binding for a
workspace — that namespace belongs to `haus.roster` app
launch keys, one of which can double as this workspace's "open
something here" action by being one of its `apps`. null means the
workspace is reachable only by launching an app that belongs to
it (or not by keyboard at all). Must be unique across workspaces,
and ⇧\ must not collide with a built-in launch-mode binding
(⇧1-4 are taken).
Example:
```nix
"c"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
### haus.ui [#hausui]
One number for "make the interface bigger", applied across haus's own surfaces.
#### `haus.ui.scale` [#hausuiscale]
`integer or floating point number between 0.5 and 3.0 (both inclusive)` · default `1.0`
One number for "make the interface bigger". 1.0 is haus as tuned;
1.35 is a comfortable large-print setting; below 1.0 tightens things up.
It sets the DEFAULT of the sizes it drives, so anything you pin by hand
still wins:
haus.ui.scale = 1.5; # everything grows
haus.fonts.mono.size = 18; # …except the terminal, pinned here
What it currently moves:
* the terminal font size (haus.fonts.mono.size)
* the command palette, whole (haus.pounce.scale) — its rows,
text and icons, and the emoji / clipboard / screenshots / camera /
Find Files / cheatsheet panels behind it
* the type in Sill's menu bar — pill labels, icons and popup rows —
up to a ceiling; see below
* the Dock icon size (system.defaults.dock.tilesize)
* Finder's sidebar rows (NSTableViewDefaultSizeMode) — a threshold
rather than a multiplier, and it is set at every scale: at or below
1.0 haus picks SMALL rows (more fits in a tiled window), above
1.0 it picks Apple's large ones
* prowl's window gaps
That list is pinned by `nix flake check`'s `scale-reach`, which
fingerprints every surface it names at four scales — so a wire dropped
in a refactor fails a check instead of quietly ceasing to arrive.
Where it stops, and why it isn't a gap waiting to be filled:
* Sill's bar HEIGHT. The bar is 36pt with 28pt pills so the pills sit
inside the 32pt menu-bar band that macOS's own hover-reveal covers;
taller pills poke out below it. That band is macOS's, fixed, and has
no setting behind it — measured, not assumed. So the bar's type
follows this option up to the largest that still fits a pill
(1.25x) and then stops, silently: past that a machine simply gets the
ceiling. The only way to make the whole bar bigger is to change what
a point MEANS — the display's scaled resolution, below.
* perch, the notch shelf. It sizes itself from the SCREEN — a fraction
of the display's width, clamped — which is the right answer for a
thing hanging off the notch, and it means NEITHER lever moves it: a
scaled display shrinks the shelf's width in points by the same
factor that makes a point bigger, so it stays the same physical
size while everything around it grows. A large-print machine gets a
normal-sized shelf, and there is no option here that changes that.
* anything outside nebelhaus. macOS has no system-wide UI scale, so
third-party apps follow only a display-resolution change.
Worth knowing if you set both: this and
`haus.displays..uiScale` MULTIPLY. A larger-text display mode
leaves a smaller desktop in points, and this asks for bigger points
inside it — so 1.4 on an already-scaled display is a bigger jump than
1.4 on the panel's default.
Example:
```nix
1.35
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
### haus.keys [#hauskeys]
The keys haus owns — the leader, the palette, the window-chord modifier — and anything extra you hang off the leader.
#### `haus.keys.leader` [#hauskeysleader]
`one of "caps", "alt-space", "none"` · default `"none"`
What enters the launcher/leader mode — tap it, then a letter opens an
app, a digit focuses a workspace, ⇧+either throws the focused window
to that workspace and follows it there, an arrow navigates, `-`/`=`
resizes.
* "caps" (default): Caps Lock. AeroSpace can't bind Caps Lock itself,
so haus remaps it to F18 with hidutil and binds that.
* "alt-space": the leader without giving up Caps Lock. No remap at all.
* "none": no leader. Caps Lock stays Caps Lock, launch mode is
unreachable, and nothing is remapped — the setting for a mouse-first
machine, or for a Mac you are handing to someone else. What the leader
fronted is still reachable: apps through the palette, window moves
through service mode's join-with and the palette's own commands.
Workspace focus and the workspace throws go away with it — they
live only in launch mode.
The remap is re-applied at every activation and does not survive a
reboot, so moving off "caps" ends it — at the latest, at next boot.
Only meaningful with haus.prowl.enable (AeroSpace owns the modes).
Example:
```nix
"none"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.keys.leaderExtras` [#hauskeysleaderextras]
`list of (submodule)` · default `[ ]`
Extra launch-mode (leader) bindings beyond the app roster: tap the leader,
then `key`, to run `command`. Use it for leader actions that aren't
"launch an app" — a script, an AppleScript, opening a URL.
Only meaningful with haus.prowl.enable and keys.leader != "none"
(with no leader there is no launch mode to bind into).
Example:
```nix
[
{
key = "enter";
command = "osascript -e 'tell application \"Things3\" to show quick entry panel'";
caption = "Things Quick Entry";
}
]
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.keys.leaderExtras.*.caption` [#hauskeysleaderextrascaption]
`null or string` · default `null`
The Launch Mode cheatsheet caption for this action. null falls back
to the raw command, which is rarely what you want — set it.
Example:
```nix
"Things Quick Entry"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.keys.leaderExtras.*.command` [#hauskeysleaderextrascommand]
`string` · no default
The shell command run when the leader is followed by `key`; launch
mode exits afterward. It's written verbatim into a small `/bin/sh`
script that AeroSpace execs, so ordinary shell rules apply — `$HOME`
resolves, and single quotes (an `osascript -e '…'`, say) are safe,
which they would not be inlined into AeroSpace's own config.
Example:
```nix
"osascript -e 'tell application \"Things3\" to show quick entry panel'"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.keys.leaderExtras.*.key` [#hauskeysleaderextraskey]
`string` · no default
The AeroSpace key name pressed after the leader (e.g. "enter",
"space", "period", or a letter). Must not collide with a roster
app's key or a built-in launch-mode key (the digits 1-4, the
arrows, `-`/`=`, `v`/`e`/`z`, `,`, `` ` ``, `/`, esc) — nor with
the workspace throws, which are ⇧ + any of those digits or a
roster letter ("shift-1", "shift-b", …). An assertion in
modules/prowl catches a clash rather than letting one binding
silently shadow another.
Example:
```nix
"enter"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.keys.palette` [#hauskeyspalette]
`one of "cmd-space", "alt-space", "ctrl-space", "none"` · default `"none"`
What opens the pounce command palette. Registered in-process by the
daemon, so it's near-instant and doesn't go through AeroSpace.
"cmd-space" (default) is the one value that also DISABLES Spotlight's
own ⌘Space, because the two can't share it. Every other value leaves
Spotlight alone — including "none", which hands the palette's job back
to Spotlight entirely. That's a fix as much as an option: haus used
to take Spotlight's ⌘Space away unconditionally, even where nothing
claimed it.
Only meaningful with haus.pounce.enable.
Example:
```nix
"none"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
#### `haus.keys.windowNav` [#hauskeyswindownav]
`one of "alt", "ctrl-alt", "cmd-alt", "none"` · default `"none"`
The modifier vocabulary for prowl's window chords — one setting rather
than a bind-per-action, because what people need to move is the
modifier, not the letters. It drives focus (`` + hjkl), layouts
(`` + `/` `,`), fullscreen, moving a workspace to the next
monitor (`⇧⇥`), and entering service mode
(`⇧;`). Anything that names a workspace — focusing one, or
throwing the focused window there — hangs off `leader` instead, not
this option.
"alt" (default) is ⌥. The alternatives are for **non-US keyboard
layouts**, where ⌥+letter types accented characters — a machine that owns
⌥+letter is unusable on those, which is the concrete reason this option
exists.
Whatever you pick, AeroSpace claims those chords **globally**, so they
stop reaching whatever owned them inside a terminal. The surface is
small now that the workspace throws moved to the leader: only hjkl,
`/` `,`, `f`, `⇧⇥` and `⇧;`, none of which a roster letter can land
on — and `⇥` is free again, since workspace back-and-forth
retired in favour of pounce's cross-workspace ⌘⇥ switcher. (Under
"ctrl-alt" that used to bite — the throws were `⌃⌥⇧` + an app's roster
letter, so an app on `a` silently ate hearth's zellij
`Ctrl Alt Shift a` in-place-agent bind. That collision is gone.)
Nothing on a stock macOS collides either: the only ⌃⌥ system hotkeys
are input-source switching (⌃⌥Space, off by default) and hyper-F13.
"none" drops the modifier chords entirely: no focus/layout chords, no
service mode. Combined with `leader = "none"` that's a machine where the
tiler tiles and the keyboard is left alone — mouse-first. The cheatsheet
follows, so it never advertises a key that does nothing.
Only meaningful with haus.prowl.enable.
Example:
```nix
"ctrl-alt"
```
Declared in
[`modules/options.nix`](https://github.com/hausfold/haus/blob/main/modules/options.nix)
.
### haus.tour [#haustour]
The first-run tutor.
#### `haus.tour.enable` [#haustourenable]
`boolean` · default `false`
The haus tour — a first-run tutor that walks the four moves (launch /
navigate / resize / palette) as ONE quiet pill in the bar, advancing
live as each move is detected. It never opens a window or steals
focus: a fresh machine just shows a dormant "new here?" hint, clicking
it (or `haus tour`, or ⌘Space → tour) starts the lap, right-click
hides it forever. Detection reuses signals haus already fires (the
leader-mode scripts) — no key logging, no Accessibility.
Needs prowl + sill (it silently stays out of the bar without them);
the ⌘Space step is dropped when pounce is off. Progress lives in
\~/.local/state/nebelhaus — `haus tour reset` re-arms a finished tour.
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.tour.steps` [#haustoursteps]
`null or (non-empty (list of (submodule)))` · default `null`
A community-authored tour, in order. null keeps the built-in four-move
nebelhaus tour unchanged; supplying a list replaces it, so a shared desktop
can teach its own workflow without shipping scripts or reaching outside the
`haus.*` option surface.
Detection reuses signals haus already emits. `launch`, `workspace`,
`navigate` and `resize` need prowl; `palette` needs Pounce and its palette
binding. The module warns when a chosen detector's room is disabled.
Authoring a tour is also the ONLY way to have one without prowl: the
built-in lap is three leader moves plus the palette, so `tour.enable` on a
machine with `prowl.enable = false` draws nothing at all.
`desktops/everyday.nix` is the worked example — one step, the launcher.
Example:
```nix
[
{
detect = "palette";
hint = "Press {palette}, type tour, then hit ↵";
}
]
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.tour.steps.*.detect` [#haustourstepsdetect]
`one of "launch", "workspace", "navigate", "resize", "palette"` · no default
The existing haus signal that completes this step: entering launch,
navigate or resize mode; changing workspace; or running the Haus Tour
command from Pounce (`palette`). The tour observes outcomes, never
keystrokes. Clicking the pill still skips a step that cannot be
detected in the current setup.
Example:
```nix
"palette"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
#### `haus.tour.steps.*.hint` [#haustourstepshint]
`string` · no default
The instruction shown in the tour pill for this step.
Name keys with the placeholders `{palette}`, `{leader}` and
`{leaderName}` rather than typing a chord: they expand to what
THIS machine resolved, so a tour written once still teaches the
right keys on a machine that moved `keys.palette` or `keys.leader`.
A hardcoded "⌘Space" is wrong on that machine and the author
never sees it — the consumer does.
Example:
```nix
"Press {palette}, type calendar, then hit ↵"
```
Declared in
[`modules/sill/options.nix`](https://github.com/hausfold/haus/blob/main/modules/sill/options.nix)
.
## Your machine [#your-machine]
The facts that are about you or this Mac rather than about a room — your commit identity, your region, this laptop's power behaviour. A shared desktop may not set them.
### haus.git [#hausgit]
Your commit identity, plus the GitHub owner this machine's work lives under — set your own. It stays in [your host file](/docs/haus/internals/flakes/#your-config-is-a-thin-consumer).
#### `haus.git.email` [#hausgitemail]
`string` · default `""`
Git user.email for commits.
Example:
```nix
"ada@example.com"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.git.name` [#hausgitname]
`string` · default `""`
Git user.name for commits (hearth wires it into home-manager).
Example:
```nix
"Ada Lovelace"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.git.org` [#hausgitorg]
`string` · default `""`
The GitHub owner whose repos this machine works on. An organisation,
or your own account: GitHub's issue search treats `org:` the
same as `user:`, so one option covers both (measured against
both qualifiers, 2026-08-08 — the counts match).
It exists because a gh-dash PR section is a GitHub search filter
scoped by `org:`. Set this **and** `haus.hearth.ghDash.enable` and
Hearth renders four PR tabs for that owner — the open / green / red /
just-shipped work. On its own it does nothing: it is the dashboard's
scope, not a feature of its own.
Leave it empty (the default) and Hearth writes no PR tabs at all, so
gh-dash keeps its own and a host composing a queue in
`programs.gh-dash.settings` never fights one. Empty is the right
answer for a machine that reads several owners at once: there is no
single owner to render. The issue and notification tabs are unaffected
either way — they ask who you are (`@me`, `is:unread`) rather than
where you work, so the dashboard ships them regardless.
Where it earns its keep is a rename: an org that changes name, or a
repo set that moves between orgs, is one word here rather than one per
tab. A host's `repoPaths` can follow the same word instead of
repeating it — read it as `config.haus.git.org` from a darwin-level
module, or as `osConfig.haus.git.org` from inside
`home-manager.users.`, where `config` is home-manager's and
carries no `haus.*` at all.
Example:
```nix
"nebelhaus"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.git.shellAliases` [#hausgitshellaliases]
`attribute set of (null or string)` · default `{ }`
Per-host additions and overrides for Hearth's built-in Git shell
aliases. Values are shell command strings; null removes a built-in.
Hearth deliberately owns a compact, framework-independent default
set, so this changes only Git shortcuts and does not require a shell
plugin manager.
Example:
```nix
{
gst = "git status --short --branch"; # replace a built-in
gsync = "git pull --rebase --autostash"; # add one
gco = null; # remove one
}
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
#### `haus.git.signingKey` [#hausgitsigningkey]
`string` · default `""`
GPG key id for signing commits/tags. Empty disables commit signing.
Key material + any YubiKey/smartcard setup live outside Nix
(gpg-agent + pinentry-mac).
Example:
```nix
"6F7BD6F43A7C1420"
```
Declared in
[`modules/hearth/options.nix`](https://github.com/hausfold/haus/blob/main/modules/hearth/options.nix)
.
### haus.locale [#hauslocale]
Language, region, units and keyboard layouts. What a machine in any language other than English needs — and the one room whose settings reach apps you already have open, because haus posts the change notification macOS itself posts.
#### `haus.locale.hourFormat` [#hauslocalehourformat]
`null or one of "12h", "24h"` · default `null`
Force 12- or 24-hour time everywhere, overriding whatever `region`
implies. null (the default) follows the region.
System-wide, unlike `haus.menuBar.clock.format`, which is only the
menu bar clock's own key. Setting both is fine and normal; setting
only this one still changes the menu bar, because the clock has no
opinion of its own until you give it one.
Example:
```nix
"24h"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.locale.inputSources` [#hauslocaleinputsources]
`null or (list of string)` · default `null`
The keyboard layouts available in the input menu, by input-source id
(`com.apple.keylayout.*`). null (the default) leaves your layouts
alone. List them with:
```
hausax input-sources --all
```
THIS ONE OWNS THE LIST. Unlike every other option in §5.6's groups, a
non-null value here is exhaustive: layouts you don't name get
disabled, because "add these and keep whatever else was there" makes
a machine that can never remove a layout it once added. Non-keyboard
input methods (emoji picker, press-and-hold) are never touched.
Applied through the documented Text Input Sources API rather than by
writing `com.apple.HIToolbox` directly. The plist route does work, but
it resolves a layout by an English display name (`Swiss French`, not
`SwissFrench`) next to a numeric id that is required and never
validated — a table haus would have to hardcode and would get
wrong for exactly the layouts nobody here tests.
Example:
```nix
[
"com.apple.keylayout.US"
"com.apple.keylayout.German"
]
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.locale.language` [#hauslocalelanguage]
`null or (list of string)` · default `null`
Preferred languages, best first — the order System Settings ▸ General
▸ Language & Region shows. null (the default) leaves macOS's own list
alone.
Apps use the first entry they have a translation for, so a list is a
fallback chain, not a single choice.
TAKES EFFECT ON RELAUNCH: an app picks its language when it starts.
Already-open apps keep the old one until you quit and reopen them,
and the login window follows at next login. Nothing haus can post
changes that — it is how bundle resources load.
Example:
```nix
[
"de-DE"
"en-GB"
]
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.locale.metric` [#hauslocalemetric]
`null or boolean` · default `null`
Use the metric system, overriding whatever `region` implies. null
(the default) follows the region.
Writes BOTH keys macOS keeps for this (`AppleMetricUnits` and
`AppleMeasurementUnits`), because it writes both itself and only one
of them is load-bearing — setting the friendlier-looking
`AppleMeasurementUnits` alone leaves a plist that reads right and a
machine that ignores it.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.locale.region` [#hauslocaleregion]
`null or string` · default `null`
The region whose formats macOS uses — dates, number separators, paper
size, the first day of the week. An ICU locale identifier
(`de_DE`, `en_GB`, `fr_CA`). null (the default) leaves macOS's own
choice alone.
This is the lever with the most reach in the group: it moves the hour
format, the measurement system and the first weekday together. Set it
before reaching for the individual overrides below — and note there is
deliberately no `firstWeekday` option, because macOS's own
`AppleFirstWeekday` key is stored and then ignored (measured; it is
the second dict-valued key in this domain found to do that). The
region's own answer is the only one that applies.
Example:
```nix
"de_DE"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.locale.temperature` [#hauslocaletemperature]
`null or one of "celsius", "fahrenheit"` · default `null`
Temperature unit, overriding whatever `region` implies. null (the
default) follows the region. Separate from `metric` because macOS
keeps it separate — a metric machine reporting °F is a real
combination, not a mistake.
Example:
```nix
"celsius"
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
### haus.power [#hauspower]
Sleep timers and Low Power Mode, said separately for battery and charger — which is the whole point, and why this is built on `pmset` rather than on nix-darwin's own power options.
#### `haus.power.computerSleep.battery` [#hauspowercomputersleepbattery]
`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null`
Minutes of idleness before the Mac sleeps while on battery, or
`"never"`. null (the default) leaves macOS's own choice alone.
A desktop Mac has no battery profile to write, so `pmset` warns
and the rebuild carries on — set the `charger` half there.
Example:
```nix
10
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.computerSleep.charger` [#hauspowercomputersleepcharger]
`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null`
Minutes of idleness before the Mac sleeps while on the charger, or
`"never"`. null (the default) leaves macOS's own choice alone.
A desktop Mac has no battery profile to write, so `pmset` warns
and the rebuild carries on — set the `charger` half there.
Example:
```nix
10
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.diskSleep.battery` [#hauspowerdisksleepbattery]
`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null`
Minutes of idleness before the disk spins down while on battery, or
`"never"`. null (the default) leaves macOS's own choice alone.
A desktop Mac has no battery profile to write, so `pmset` warns
and the rebuild carries on — set the `charger` half there.
Example:
```nix
10
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.diskSleep.charger` [#hauspowerdisksleepcharger]
`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null`
Minutes of idleness before the disk spins down while on the charger, or
`"never"`. null (the default) leaves macOS's own choice alone.
A desktop Mac has no battery profile to write, so `pmset` warns
and the rebuild carries on — set the `charger` half there.
Example:
```nix
10
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.displaySleep.battery` [#hauspowerdisplaysleepbattery]
`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null`
Minutes of idleness before the display sleeps while on battery, or
`"never"`. null (the default) leaves macOS's own choice alone.
A desktop Mac has no battery profile to write, so `pmset` warns
and the rebuild carries on — set the `charger` half there.
Example:
```nix
10
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.displaySleep.charger` [#hauspowerdisplaysleepcharger]
`null or positive integer, meaning >0, or value "never" (singular enum)` · default `null`
Minutes of idleness before the display sleeps while on the charger, or
`"never"`. null (the default) leaves macOS's own choice alone.
A desktop Mac has no battery profile to write, so `pmset` warns
and the rebuild carries on — set the `charger` half there.
Example:
```nix
10
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.lowPowerMode.battery` [#hauspowerlowpowermodebattery]
`null or boolean` · default `null`
Low Power Mode while on battery. null (the default) leaves
macOS's own choice alone.
The setting with the clearest opinion in this group for a laptop:
on for battery, off for the charger, is what most people want and
almost nobody sets.
Example:
```nix
true
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
#### `haus.power.lowPowerMode.charger` [#hauspowerlowpowermodecharger]
`null or boolean` · default `null`
Low Power Mode while plugged in. null (the default) leaves
macOS's own choice alone.
Example:
```nix
false
```
Declared in
[`modules/den/options.nix`](https://github.com/hausfold/haus/blob/main/modules/den/options.nix)
.
# Troubleshooting (/docs/haus/reference/troubleshooting)
Almost every haus problem is one of a few known macOS quirks with a known fix.
Start here, whatever the symptom:
```sh
haus doctor # Nix, the Xcode tools, every GUI agent, cask drift, the
# launcher's Accessibility grant, the theme ports and your
# secrets, in one pass
```
Every agent also keeps a log in `/tmp`: `aerospace`, `sketchybar`,
`sill-bottom` and `pounce` each write `.out.log` and `.err.log`
there. Reading the error log is usually faster than restarting blind, and an
agent that dies again ten seconds after a kick is telling you something that
only shows up in it.
## ⌘Space does nothing [#space-does-nothing]
Pounce is a launchd **user agent**. If it isn't answering, kick it. This is the
clean recovery for *any* wedged agent, and the rest of this page reuses it with
a different label:
```sh
launchctl list | grep pounce # is it running at all?
launchctl kickstart -k gui/$(id -u)/com.hausfold.pounce
```
If the palette opens but **clipboard and emoji paste** don't work, it's missing
the Accessibility grant:
```sh
pounce --request-accessibility # approve the system dialog
pounce --check-accessibility # prints true once granted
```
Set `haus.pounce.signingIdentity` so that grant survives rebuilds; see
[Granting Accessibility](/docs/pounce/install#grant-accessibility). If
⌘Space still opens **Spotlight**, log out and back in once so the
symbolic-hotkey reassignment takes.
Another hotkey tool (skhd, AeroSpace, Raycast) bound to the same key grabs it
with an event tap *ahead* of Pounce, so every summon spawns a fresh client
instead of hitting the daemon's fast path. Registration still reports success
and the palette still opens, which is what makes it hard to spot. `pounce
doctor` names the culprit and tells you what to unbind.
One line of its output is wrong on a haus machine: if the daemon isn't running
it suggests `brew services start pounce`, which is the advice for a standalone
Homebrew install. Here the daemon is the launchd agent above; kick that
instead.
## Windows won't tile [#windows-wont-tile]
Almost always the **cold-boot race**: an agent that launched before the desktop
session was ready parked itself instead of starting. Kick it:
```sh
launchctl kickstart -k gui/$(id -u)/org.nixos.aerospace
```
The smaller ones:
* **Scrambled after sleep, or a window on the wrong workspace?** Tap ⇪ then
\` to re-sort everything to its roster home.
* **A binding you changed didn't take?** A rebuild rewrites the config, but the
live daemon holds the old one until it reloads. Force it with `aerospace
reload-config`, or run **Reload AeroSpace** from the ⌘Space palette.
## The bar is blank or missing [#the-bar-is-blank-or-missing]
```sh
launchctl kickstart -k gui/$(id -u)/org.nixos.sketchybar
launchctl kickstart -k gui/$(id -u)/org.nixos.sill-bottom # if you run the bottom bar
```
If `/tmp/sketchybar.err.log` says `could not acquire lock-file … already
running?`, a stray `brew services` copy of SketchyBar is holding the lock. Every
rebuild evicts it, so `haus rebuild` is the fix.
**Running, but stale** (a pill you added isn't there, or a colour change didn't
land)? The easy fix is **Reload SketchyBar**, from the ⌘Space palette or from the
haus menu on a left-click of the logo pill. By hand:
```sh
sketchybar --reload ~/.config/sketchybar/sketchybarrc # the menu-bar bar
sill-bottom --reload ~/.config/sketchybar/sill-bottomrc # the bottom bar, if enabled
```
`--reload` on its own doesn't mean "re-read your config"; it means *re-run the
path you resolved at startup*, and that path is a symlink into `/nix/store`
resolved once. So a bare reload can quietly replay the config from the
generation the bar booted on while exiting 0 and logging success. Naming the
path re-resolves it now. And the two bars are separate instances: a lone
`sketchybar --reload` leaves the bottom one exactly as it was.
If you'd rather not run a custom bar at all, `haus.sill.enable = false` brings
the native macOS menu bar back.
## After a macOS upgrade, *every* agent is dead [#after-a-macos-upgrade-every-agent-is-dead]
If the bar, tiling and ⌘Space all come up dead at once right after upgrading to
**macOS 26 Tahoe or later**, the culprit is **Background Task Management**.
Tahoe gates login items whose executable isn't Apple-signed, and every Nix agent
launches through `/bin/sh -c "…"`, which BTM files under "unidentified
developer" and can silently refuse to start. They register fine; they just never
run.
```sh
haus btm # no-op before Tahoe; on Tahoe+ it reads the BTM store and instructs
```
There is **no declarative fix**: the toggle lives in macOS's own BTM store,
which has no CLI to set it. So it's a one-time manual step: in **System Settings
▸ General ▸ Login Items & Extensions**, scroll to **Allow in the Background**,
find the entries named **`sh`** (subtitled *Item from unidentified developer*),
switch them on and reboot. Already on but still blocked? Flip off, then on, to
force a database write.
You can read the store yourself at any time:
```sh
sudo sfltool dumpbtm | grep -B2 -A8 -iE "nixos|hausfold|darwin-store"
```
Look for `disallowed`, and search all three names: the launcher registers under
`com.hausfold.pounce` while tiling and the bar are still `org.nixos.*`, so a
grep for `nixos` alone reports a clean store while the launcher is the thing
that's blocked.
## Every agent is dead, and macOS didn't change [#every-agent-is-dead-and-macos-didnt-change]
Same symptom, different cause, and this one needs no upgrade: **a rebuild that
aborted before it got to them.** If your host file writes
`system.defaults.universalaccess.*`, activation writes those keys two thirds of
the way through, and without Full Disk Access that write fails, takes the rest
of activation with it, and never reaches the launchd agents. The failure is
nowhere near the symptom.
`haus doctor`'s **Permissions** section reports the grant, `haus plan` says
whether this rebuild needs it before you run one, and `haus rebuild` refuses up
front whenever the app it runs under lacks it, agent pane or plain terminal
alike, since the grant follows the app. Better still, sidestep the domain:
`haus.accessibility.*` reaches every key in it that macOS actually honours
(all seven, which is every key nix-darwin types there) with a guarded write,
which costs you the setting and nothing else when the grant is missing. [Changing your Mac with an
agent](/docs/haus/rooms/agent-rebuilds) has the detail.
## Touch ID for sudo beachballs inside a multiplexer [#touch-id-for-sudo-beachballs-inside-a-multiplexer]
The `pam_reattach` shim isn't loading. It ships with the **collar** room and is
on by default; check the order in `/etc/pam.d/sudo_local`: reattach must come
*before* the Touch ID line. Outside tmux or zellij, Touch ID should just work;
cancel the prompt to fall back to your password. [Touch
ID](/docs/haus/rooms/security) has the rest.
## "Refusing to load cask … from an untrusted tap" [#refusing-to-load-cask--from-an-untrusted-tap]
Third-party taps fail Homebrew's trust check under a sudo-driven activation.
haus turns that requirement off globally
(`HOMEBREW_NO_REQUIRE_TAP_TRUST=1` in `/etc/homebrew/brew.env`), so seeing this
means the file didn't land. Re-run `haus rebuild`, or export the variable in
your shell for a one-off.
## An app I removed from my config is still installed [#an-app-i-removed-from-my-config-is-still-installed]
By design. `haus.homebrew.cleanup` is `"none"`, so nothing you installed is ever
auto-deleted, and **`haus rollback` doesn't rewind Homebrew apps**, because
they aren't in Nix generations. Remove one by hand:
```sh
brew uninstall --zap
```
For a machine where an undeclared cask *is* removed on the next rebuild, set
[Customize a desktop](/docs/haus/desktops/customizing#homebrew-behaviour).
## A rebuild broke something [#a-rebuild-broke-something]
```sh
haus rollback # atomically back to the previous generation
haus generations # what you can roll back to
```
`haus rebuild` always builds *before* it switches, so a config with an error in
it can't activate: you get a build failure and a machine exactly as it was. For
macOS **settings** rather than packages, `haus revert-settings` puts back the
snapshot `haus capture` took, and the APFS snapshot from install time is the
coarser rewind.
Want to go further than one generation (switch a room off, or remove haus
entirely)? [Leaving](/docs/haus/leaving) walks each exit, smallest first.
## "Found a Nix at /nix that isn't Determinate" [#found-a-nix-at-nix-that-isnt-determinate]
haus is built on [Determinate Nix](https://docs.determinate.systems/), and the
installer stops on any `/nix` that Determinate didn't create (single-user or
multi-user daemon install alike, it's the *flavour* it checks) rather than risk
breaking the Nix you already have. If you
have a Nix you don't need, uninstall it and re-run the one-liner; if you want to
keep it, migrating to Determinate is the supported path. A fresh Mac needs none
of this; the bootstrap installs Determinate for you.
## Still stuck? [#still-stuck]
Still nothing? [Open an issue](https://github.com/hausfold/haus/issues) with the
output of `haus doctor`.
# Changing your Mac with an agent (/docs/haus/rooms/agent-rebuilds)
Point an AI agent at a normal Mac and it `brew install`s things, writes
dotfiles, and clicks through System Settings on your behalf. On a haus machine
most of that would be silently undone by the next rebuild, with no record of
what changed.
Declarative config flips that. Every change is text in one file, `haus rebuild`
**builds before it switches** so a broken edit never reaches the running system,
and `haus rollback` puts the last one back in seconds. That makes yours the rare
machine an agent can reconfigure without it being reckless.
The safety was always there; the missing half was knowledge. So haus ships it.
## The skill haus writes [#the-skill-haus-writes]
Every rebuild writes a **skill** called `haus` into the skills directory of each
client in `haus.ai.clients`: `~/.claude/skills/haus` for Claude Code,
`~/.codex/skills/haus` for Codex, `~/.config/opencode/skills/haus` for OpenCode.
Name no clients at all (or turn the room off, which resolves to the same empty
list) and all three get a copy, so the skill is never the missing piece. One body, one copy per client:
| File | What it is |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `SKILL.md` | The loop, the boundaries, the traps. |
| `references/options.md` | Every `haus.*` option, **generated from the exact revision you're pinned to**. |
| `references/this-machine.md` | Your host: hostname, host-file path, which rooms are on, your theme and keys, your app roster and its taken leader keys, plus the rebuild hazards specific to this machine. |
| `references/recipes.md` | Worked examples for the common asks. |
| `consumer-AGENTS.md` | A starter `AGENTS.md` for your own config repo. |
| `consumer-CLAUDE.md` | Its one-line `@AGENTS.md` pointer, for the client that reads only `CLAUDE.md`. |
The option reference being *generated* is the point: it can only describe
options that exist on your revision (a confidently-wrong option name costs you
a failed build), and `haus update` regenerates it with the rest.
`haus doctor` has an **Agents** section: whether the skill is installed (it
checks every client's directory and names the copy it found), whether your
config repo has an `AGENTS.md` and the `CLAUDE.md` pointer beside it, and
whether the app you're running from can write macOS's protected settings.
## The loop it follows [#the-loop-it-follows]
**Orient**: read `references/this-machine.md` and your host file, and check
`haus status`.
**Find the option**: grep the generated reference. If it isn't listed, it
doesn't exist on your revision, and the agent should say so rather than guess.
**Edit `~/.config/nix/hosts//default.nix`**, the one file you own.
**Apply with `haus rebuild`**: the build gates the switch.
**Verify, or `haus rollback`.**
## What to ask for [#what-to-ask-for]
Ordinary requests, in your own words:
* *"Install Slack and give it its own workspace"* → an entry in `haus.roster`
for the leader key and the Homebrew cask, paired with one in
`haus.workspaces` for the workspace and its bar pill.
* *"Everything's too small"* → `haus.ui.scale`.
* *"Switch to light mode"* → `haus.theme.flavor` plus
`haus.theme.systemAppearance`, so macOS's own Light/Dark moves with the rest.
* *"Hide the weather in the bar"* → the matching `haus.sill.items.*` toggle.
* *"Bind a key to open my notes"* → `haus.keys.leaderExtras`.
The skill tells the agent to prefer a `haus.*` option over a raw nix-darwin
setting, and to say so when nothing covers what you asked, because that's a gap
in haus, not something to bolt onto your machine.
## What it won't do [#what-it-wont-do]
* **Touch identity or secrets** without asking: git identity, signing keys,
`haus.secrets.*`.
* **Edit haus itself, `flake.lock`, or anything in `/nix/store`.** Pulling a
newer revision is `haus update`, not a hand edit.
* **Uninstall apps behind your back.** Removing an app from your config stops
haus *managing* it; the app stays on disk until you `brew uninstall --zap` it.
If your host file sets `system.defaults.universalaccess.*` directly, `haus
rebuild` refuses to run whenever the app it runs under lacks **Full Disk
Access**, and tells you how to get past it.
That domain is TCC-protected, and the grant belongs to the app the rebuild runs
under, not to you and not to root. So this is **not an agent rule**: an agent
pane inside a terminal that holds the grant rebuilds perfectly well, and you at
that same keyboard, in a terminal nobody has granted, are refused identically.
Without the grant the write fails partway through activation and takes
everything after it down too: your bar, tiling and palette come back dead, with
the symptom nowhere near the cause. Refusing is the kinder failure: nothing is
changed, and your edit is still on disk.
The way past it is always to stop writing the domain by hand.
`haus.accessibility.*` reaches every key in it that macOS actually honours
(seven of them, which since August 2026 is **every key nix-darwin types there**,
so the raw form no longer reaches anything the safe one doesn't) with a
**guarded** write, so without the grant you lose that setting and nothing else,
and the rebuild runs from anywhere. Otherwise:
run it from an app that holds the grant, or set `HAUS_FDA_ANYWAY=1` when you know
better (`HAUS_AGENT_REBUILD=1`, the name this had while the check was
agent-scoped, still works).
`haus plan` tells you which grant a rebuild wants *before* it runs, and `haus
doctor`'s **Permissions** section says whether this app has it.
`haus.theme.contrast` is a different thing again: haus's own palette axis,
painted into the apps haus themes rather than into macOS, so it always applies.
## What a rollback doesn't undo [#what-a-rollback-doesnt-undo]
`haus rollback` rewinds everything Nix manages, instantly. It does **not** rewind
macOS system settings a rebuild wrote (Dock, keyboard, Finder), and it does not
rewind Homebrew casks; those live outside Nix generations entirely. A good
agent says which kind of change it's about to make.
## An AGENTS.md for your own config [#an-agentsmd-for-your-own-config]
`~/.config/nix` is your own git repo, your machine in text. Dropping an
`AGENTS.md` in it orients any agent you open there, whether or not it found the
skill. A starter pair ships inside the skill; nothing writes it for you, because
it's your repo:
```sh
# whichever of these exists on your machine; the copies are identical:
# ~/.claude/skills/haus · ~/.codex/skills/haus · ~/.config/opencode/skills/haus
skill=~/.claude/skills/haus
install -m 644 "$skill"/consumer-AGENTS.md ~/.config/nix/AGENTS.md
install -m 644 "$skill"/consumer-CLAUDE.md ~/.config/nix/CLAUDE.md
```
`install -m 644`, not `cp`: the starter pair are symlinks into the Nix store,
whose files are read-only. A plain `cp` preserves that mode, so you land on an
`AGENTS.md` your editor refuses to save, on the one file the whole point of
copying is to then edit.
Two files, because the tools disagree about the filename. **`AGENTS.md` carries
the rules**: Codex, OpenCode, Cursor, Zed, Copilot and everything else that
speaks [agents.md](https://agents.md) reads it directly. Claude Code reads only
`CLAUDE.md`, so the second file is a one-line `@AGENTS.md` import and nothing
else. Copy one and you orient some of your tools and not others.
## Your own instructions, in every client [#your-own-instructions-in-every-client]
`haus.ai.instructions` is the other half of what haus writes into an agent's
home: your always-on operating context, rendered once per installed client at
the path that client reads (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`,
`~/.config/opencode/AGENTS.md`), under a short generated preamble.
```nix
haus.ai.instructions = ''
# Global instructions
Ship small, verified changes. Ask before anything hard to reverse…
'';
```
Write it **client-neutrally**: the same text reaches whichever agent your ⌘A pane
spawned, so a line about one client's flags is noise in the other two. Empty (the
default) writes nothing at all, so a hand-managed instructions file is never
touched. *Setting* it on a machine that already has one, though, moves yours
aside to `.backup`. And `haus.ai.skill = false;` stops the skill being written
at all, for when you'd rather own each client's skills directory yourself.
(Both were `haus.claude.globalMd` and `haus.claude.skill` until August 2026; the
old names still evaluate, with a warning.)
An agent that can rebuild your Mac can change how your Mac behaves. The safety
here is real, but it is *recovery*, not prevention: the build gates the switch
and the rollback is atomic, so mistakes are cheap, not impossible. Read the diff
before you say yes, exactly as you would for any other change to your config.
# AI (/docs/haus/rooms/ai)
Run two coding agents in the same checkout and they fight: one switches the
branch out from under the other, uncommitted edits collide, and closing a pane
mid-thought can lose work. **`holt`** fixes that by giving every agent its own
checkout, and haus puts it on your `PATH`, wired to a keybind.
Press **⌘A** in any repo's terminal tab and you get a fresh agent session in an
isolated git worktree: its own checkout on a `worktree-` branch, branched
from the repo's local `HEAD`, living **outside** the repo under
`~/.cache/claude-worktrees//` so your working tree stays clean and no
build tool trips over it. **⌘⇧A** does the same in place of the focused pane
rather than beside it; the pane it replaces is only suspended, so quitting the
agent brings your shell back with its scrollback. **⌃⌥⇧A** is the resident
variant: the one agent per tab allowed to edit the checkout you're looking at.
holt calls what the first two make a **lane** (one agent's branch, checkout and
terminal, as a single thing you can name), and that's the word its output uses.
Not a *worktree*: a parked lane has no checkout on disk and is still a lane you
can resume. (⌃⌥⇧A makes no lane at all; it's an agent in the checkout you
already have.) Where a lane's terminal lives is the
[Development](/docs/haus/rooms/development) room's call rather than holt's:
`haus.hearth.lanes.backend` puts it in a zellij pane by default, or in a window
of its own.
It works on **any git repo**, and it isn't really a haus feature: it's
[its own tool](https://github.com/hausfold/holt), which haus takes as an input
and puts on your `PATH`. You don't have to be a haus user to run it.
Closing a ⌘A pane doesn't just delete the checkout. Uncommitted edits are first
parked as a `wip:` commit on the branch, and **only branches whose work has
already merged get reaped**: everything unmerged survives on its branch, and the
agent's transcript survives with it. The checkout is disposable; your work isn't.
## Enable it [#enable-it]
```nix
haus.ai = {
enable = true;
clients = [ "claude" "opencode" ];
default = "claude";
};
```
That brings the clients you named, `holt`, and the worktree lifecycle wiring.
Nothing else in the house has to be on for it to work.
## Resuming [#resuming]
Because the work is on the branch and the chat is in your client's transcript, a
closed pane is never a dead end. Run `holt` bare to see everything you can pick
back up, across every repo:
```
🌫 lanes you can resume (holt , or /)
repo name state agent last commit
nebelung sparkle parked claude 2 hours ago — wip: auto-saved on pane close
pounce quick-emoji live codex 10 min ago — add fuzzy weights
```
Name one and holt rebuilds the checkout and picks the conversation back up
through whichever client that lane was made with:
```sh
holt sparkle # resume it
holt nebelung/sparkle # qualify when the name exists in two repos
holt sparkle --pick # choose from that client's session list instead
```
By default it continues the newest conversation. `--pick` opens the client's own
picker, for when a lane has more than one.
## Spawning from the launcher [#spawning-from-the-launcher]
For a lane **named after the task** rather than the random
`luminous-twirling-codd` an unattended agent would coin, summon
the [launcher](/docs/haus/rooms/launcher) and run **Spawn Agent**: pick a repo, describe
the job, and it creates the checkout and opens a session in that repo's zellij
tab. Naming the branch from the prompt is the whole point.
How you commit the prompt picks what happens next:
| Key | Does |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `↵` | Spawn on what you typed |
| `⇧↵` | Newline; a task is usually a list, not a sentence |
| `⌘↵` | Grab a screenshot first, then spawn; cancel the capture and you're back in the box with your text intact. On an empty box it just attaches the shot and hands the box back |
| `⌥↵` | Open your drafts; the box is saved on every dismissal, so a paragraph lost to a stray click is still here |
Codex receives the screenshot as an image; Claude Code and OpenCode get its path
in the first prompt.
## Which client gets spawned [#which-client-gets-spawned]
```nix
haus.ai.clients = [ "claude" "opencode" ]; # what's installed
haus.ai.default = "claude"; # what ⌘A spawns
```
`ai.default` must be one of `ai.clients`, and the rebuild says so by name if it
isn't rather than failing later, inside the pane, after the checkout already
exists. `clients` defaults to an empty list, which is exempt from that check
(there's nothing to be absent from) and means haus installs no client itself.
All of it hangs off one master switch, `haus.ai.enable`: holt on your `PATH`, the
⌘A keybinds, the tab marks, the statusline, the client wiring. Rooms are
independent, so the switch is yours (or your desktop's) to set; hacker turns
it on and names both options above. Turn the room off and `ai.clients` is ignored
whole rather than half-wiring the machine, so a host can drop the room without
having to blank the list its desktop set.
holt records the client **with the lane**, so changing the option only affects new
spawns: a parked Codex task still reopens through `codex resume --last`, and an
OpenCode one through `opencode --continue`.
Want a patched build of a client? **Overlay the package** (`claude-code`,
`codex`, `opencode`) rather than putting your own derivation in `home.packages`
beside it. Two derivations shipping the same `bin/claude` collide in one profile,
and the rebuild fails with `two given paths contain a conflicting subpath`.
## Never `git stash`: park instead [#never-git-stash-park-instead]
`git stash` looks like it belongs to the checkout you're standing in. It doesn't.
The stash is one ref in the repo's shared `.git` directory, so **every worktree
of a repo, and the main checkout, push and pop the same stack.** Two agents
stashing at once means either can pop the other's entry into a tree that has
never seen those files, and `stash@{1}` is no safer, being positional.
So park instead:
```sh
holt park "half-done refactor" # whole dirty tree → one wip: commit on THIS branch
holt unpark # rewind it: changes back, uncommitted
```
`holt park` commits everything (tracked edits, untracked files, deletions) as a
single `wip:` commit on the branch only this pane has checked out. Ignored files
are never swept in, so parking can't smuggle a secret into history. It's the same
thing the pane-close hook does, on demand, and `holt` lists the result as a
resumable lane rather than a numbered entry in a shared pile.
If the `wip:` commit has already been pushed (it's visible in an open PR),
`holt unpark` refuses, because rewinding it locally turns a file restore into a
force-push. It prints the escape hatch (`git reset --mixed HEAD^`) and leaves the
commit alone. Parking on a branch that isn't an agent branch works, but says so:
a `wip:` commit shouldn't drift onto a branch you push.
## Working on another repo: `holt child` [#working-on-another-repo-holt-child]
Sometimes an agent in one repo has to change **another** one. Don't reach for a
raw `git worktree add`: that skips holt's registry, so the statusline never
learns to track the lane and its PR goes invisible.
```sh
cd "$(holt child ~/code/other-repo)"
```
That makes a lane on the other repo, registers it as a **child of your current
pane** so its PR shows up where you're working, and prints only the new checkout
path, hence the `cd "$(…)"`.
Pointing agents at haus itself rather than at your own projects?
[Contributing](/docs/haus/internals/contributing) covers what's different in a
family repo: building a lane's branch without merging it, and the one
activation an agent is refused.
## Cleaning up [#cleaning-up]
Most cleanup is automatic: closing a pane reaps a merged branch, and every `holt`
listing self-heals first by sweeping parked branches whose PR has since merged.
Some things end a pane without the graceful hook, though: a reboot, a crash, or
a `holt child` checkout, which no pane owns. For those, sweep on demand:
```sh
holt reap
```
`holt reap` is deliberately conservative. It leaves anything **dirty**, anything
**unmerged**, and any checkout a **pane is still open in**: the one you're
standing in included, and any other session's too, because a just-merged branch
looks exactly like a finished one while its agent is still sitting in it. Only
landed work nobody is standing in gets removed.
Every deletion is written to a ledger before the branch dies, so a sweep is
never the end of anything: `holt reaped` prints what went, why, and the
`git branch` line that brings it back. And a lane that will *never* land (its
PR closed unmerged, the repo archived) is `holt drop `, which waives the
merged-branch requirement and nothing else.
## When a session outran its PR [#when-a-session-outran-its-pr]
You merge the PR, keep talking to the agent, and it makes a few more commits.
Those commits are now in an odd place: the PR that covered them is closed, and
GitHub deleted the branch's remote counterpart when it merged, so they're
committed, unpushed, unreviewed, on a branch nothing is watching.
holt never reaps such a branch, and it says so everywhere: the listing marks the
row **`live+3`** (three commits past the merge), `holt reap` names it instead of
sweeping it, and the statusline shows an orange `3^`. One command clears it:
```sh
holt reship # push this branch, open the follow-up PR
holt reship sparkle # …or name one, from anywhere
```
It re-creates the remote GitHub deleted and opens a PR against the default
branch, listing what's new. If a PR is already open, it just pushes to it.
The lookalike is marked differently and wants the opposite treatment: **`~3`**
means the branch has *diverged*: its tip doesn't build on what merged, so the
checkout is stale rather than ahead. `holt reship` refuses it. Remove that lane
instead of pushing it.
## Knowing which agent needs you [#knowing-which-agent-needs-you]
Run agents in eight tabs and the question is always the same one: which of them
is sitting on a permission prompt? The zellij tab bar answers it, with a coloured
dot in front of the name of any tab holding agent panes:
| Mark | Means |
| --------------------------------------- | -------------------------------------------------------------- |
| **`●` peach**, and the tab washed peach | an agent is blocked on you: a permission prompt, or a question |
| **`◐` sky** | an agent is mid-turn, working |
| **`○` green** | it finished its turn; nothing is waiting |
| *(nothing)* | no agent panes in that tab |
The dot shows the most urgent state in the tab, so peach always wins and the tab
that needs you never hides behind one that's merely busy. (The peach wash is
dropped on the tab you're already looking at.) It's the shape that carries the
state, not the colour, and there's no count: how many agents a tab holds is the
[bar](/docs/haus/rooms/bar)'s popup to answer, and the bar is the same signal for
when you're out in the GUI rather than in the terminal.
Both are reported by each client's own lifecycle hooks: the agent says what it's
doing; nothing scrapes the screen for it. haus wires OpenCode's plugin and
Codex's hooks for you. **Claude Code's four are yours to wire**, and haus
deliberately leaves them alone.
The **worktree** hooks that Claude Code's own `--worktree` flag fires:
`WorktreeCreate` and `WorktreeRemove`, pointing at `holt hook create` /
`holt hook remove`, plus the statusline, `permissions.defaultMode` and a few TUI
keys. It merges into the file rather than replacing it, touches no other hook
event, and re-asserts on every rebuild, because Claude Code rewrites its own
settings on its own schedule and a hand-written hook can go with them. You'd find
that out at pane close, by losing a lane's parking.
### The statusline HUD [#the-statusline-hud]
haus points Claude Code's status bar at a small HUD driven off holt's registry, so
you can see where every agent stands without leaving the pane. **Row one is this
session's own lane**: a status token, its GitHub PR pill, then its name. **The
rows below are the lanes this session spawned**, across whatever repos they live
in, eight at most with a `+N more` line under them. Rows that still need
attention sort above landed ones, so a clipped HUD can only ever hide something
already reapable.
The status token is one mutually-exclusive slot:
| Token | Means |
| ------------------------- | ----------------------------------------------------------------------------- |
| `⏏` | the branch has landed; closing the pane reaps the lane |
| `N^` | `N` commits on the branch, not merged yet |
| `N^` *(orange)* | the PR merged and `N` commits landed since, covered by nothing; `holt reship` |
| `+A -D` | uncommitted line changes, when nothing's committed yet |
| `●` *(muted)* | nothing to report: clean tree, nothing ahead |
Flush right on row one sit a few per-session chips: how far behind your pinned
revision you are (`⇡N`, what `haus update` would pull), context used, session
cost, the permission mode, and a short model tag. Context is coloured by **token
count**, not percentage, because the percentage means different things on a 200k
and a 1M model. Permission mode follows the same "no news is good news" rule as
the status token: the default shows nothing, everything else is a positive mark.
## Command summary [#command-summary]
| Command | What it does |
| -------------------------- | ----------------------------------------------------------------------------- |
| `holt` | List every parked and live lane across all repos (self-heals first) |
| `holt ` | Resume one: rebuild its checkout, pick its conversation back up |
| `holt reap` | Sweep every landed lane now (keeps dirty, unmerged, and any open pane) |
| `holt reaped` | The ledger of what was swept, why, and the one line that brings a branch back |
| `holt drop ` | Retire a lane that will never land: a PR closed unmerged, an archived repo |
| `holt child ` | A lane on another repo, as a child of this pane |
| `holt park [label]` | Set the dirty tree aside as a `wip:` commit; use instead of `git stash` |
| `holt unpark` | Undo it: changes back in the working tree, uncommitted |
| `holt reship [name]` | The PR merged and the branch kept going: push it, open the follow-up PR |
| `holt new [name]` | A lane on **this** repo, with the default client opened in it |
| `holt spawn ` | A named lane for a spawner with no pane of its own; what Spawn Agent calls |
Add `--json` to the listing for a machine-readable version, `--version` for
holt's own.
## Works with [#works-with]
Three contributions, each appearing only when its receiving room is on, and none
of them switching that room on for you:
* **[Development](/docs/haus/rooms/development)**: the agent keybindings and
pane lifecycle inside the multiplexer;
* **[Bar](/docs/haus/rooms/bar)**: the agents pill, amber when a session is
waiting on you;
* **[Launcher](/docs/haus/rooms/launcher)**: **Spawn Agent**, which names the
worktree after the job.
## Remove it [#remove-it]
```nix
haus.ai.enable = false;
```
The clients, `holt` and the wiring go, including from a desktop that named
clients, because the room's switch is what decides, not the list. What stays is
everything on disk: the worktrees you created and their branches are ordinary
git, and removing the room never deletes work.
## Options [#options]
Every setting, with types and defaults:
[AI](/docs/haus/reference/options#ai).
# Appearance (/docs/haus/rooms/appearance)
The whole system shares one palette: **nebelung**, a silver-mist Catppuccin
variant. It is Catppuccin with the blue pulled out of the greys and the accents
calmed down: a fog-grey scheme for people who find most dark themes too loud.
Grey is the point. It is named for a cat breed the colour of high fog.
nebelung renders that palette onto **50+ tools** (the terminal, the
multiplexer, the prompt, git, the file manager, the default editor, the
browser), so they all speak the same colours. Three knobs, in the order people
reach for them:
| Option | What it changes |
| ---------------------------------- | ------------------------------------------ |
| [`haus.theme.accent`](#the-accent) | the one hue that tints highlights |
| [`haus.theme.contrast`](#contrast) | how far text separates from its background |
| [`haus.theme.flavor`](#light-mode) | dark or light |
They compose, and all three land on the next `haus rebuild`.
## The accent [#the-accent]
The greys stay fixed; only the accent hue moves.
```nix
haus.theme.accent = "sapphire";
```
Any of the fourteen Catppuccin accent names works, and the default is `mauve`:
`rosewater` · `flamingo` · `pink` · `mauve` · `red` · `maroon` · `peach` ·
`yellow` · `green` · `teal` · `sky` · `sapphire` · `blue` · `lavender`
It re-tints the tools that render per-accent variants (lazygit borders, fzf
highlights, yazi, glow's markdown headings, the Zen browser, and the
[generated desktop](#the-desktop)), while single-file dotfiles keep their built-in
theme. That list isn't prose anyone maintains by hand: a build-time check fails
if a surface on it starts, or stops, following the accent.
The accent moves the browser's own UI. It does **not** re-tint github.com,
because those are Catppuccin *userstyles*, compiled inside the browser and
stored in the Stylus extension rather than in any file haus writes.
Declare the extension and haus closes the loop:
```nix
haus.zen.extensions.stylus = { };
```
It deploys Stylus through a macOS managed preference (`haus.roster` can't
install add-ons) and stamps nebelung's userstyle bundle from *your* accent,
flavor and contrast, so all three axes on this page reach the web. Because that
preference is root-owned, Zen will say it is **managed by your organization**;
that organization is your own config. Set
`haus.zen.extensions.stylus.mode = "normal_installed"` to keep it removable.
The import itself stays a click, because Stylus has no file interface: activation
prints a one-time nudge, and you do **Stylus ▸ Manage ▸ Import**.
## Contrast [#contrast]
The default greys are deliberately soft. If text doesn't separate enough (a
bright room, a glossy screen, or eyes that want more edge):
```nix
haus.theme.contrast = "high";
```
Same hues, same accents; the neutral ramp is pulled apart in OKLCH so every step
gains separation. Contrast is a property of the **neutrals** only, so a
high-contrast machine is the same theme read more easily, not a different one.
This recolours everything haus colours. macOS has its own switch, and a
genuinely high-contrast Mac wants both:
```nix
haus.accessibility.increaseContrast = true;
```
That one needs Full Disk Access on whatever you rebuild from.
## Light mode [#light-mode]
```nix
haus.theme.flavor = "latte";
```
Light mode is **not** the dark palette inverted. "Catppuccin with the blue
stripped out" says nothing about dark, so the same recipe is applied to
Catppuccin **Latte** instead of Mocha: the same warm-grey ramp and the same
calmed accents, read the other way up. Because it is a real re-render, tools take
their light-mode branches properly: ANSI black and white swap around, the
browser switches its `prefers-color-scheme` block, delta marks itself light.
The two axes give four palettes. Contrast of body text on the background is
measured, not eyeballed, and nebelung's CI holds every one of them above the
AAA floor:
| | `contrast = "normal"` | `contrast = "high"` |
| ------------------ | --------------------- | ------------------- |
| `flavor = "mocha"` | 11.3:1 | 19.9:1 |
| `flavor = "latte"` | 7.0:1 | 9.9:1 |
By default haus leaves **System Settings ▸ Appearance** alone in both
directions, so a light config on a dark Mac looks half-finished. One option
finishes it:
```nix
haus.theme.systemAppearance = "flavor"; # latte → Light, mocha → Dark
```
`"light"` and `"dark"` pin it instead; the default `"unmanaged"` is what leaves
it alone, because a managed default would silently undo an appearance you picked
by hand on your next rebuild. Driving it needs an **Automation** grant for whatever
runs your rebuild (System Settings ▸ Privacy & Security ▸ Automation); without
one haus tells you and changes nothing. And if Appearance is set to **Auto**,
macOS switches polarity on its own schedule; haus sets it at rebuild time and
doesn't fight it afterwards.
**pounce and perch** are the exception in the other direction: both read their
palette at runtime and follow macOS's Light/Dark themselves, so on a dark Mac
they stay dark under `flavor = "latte"`. Pin them with
`haus.pounce.followSystemAppearance = false`, needed only while macOS stays
unmanaged.
## The desktop [#the-desktop]
The wallpaper is generated on your machine rather than shipped as a picture, and
it is what you get without saying anything:
```nix
haus.wallpaper.style = "minimal"; # none | minimal | orbits | constellation | flow | bold
```
`minimal` is one flat colour out of your palette, the haus mark **⌂** at the
centre in the family's six accents, and a very broad bloom in your own accent
behind it. There is no photograph to clash with a terminal in front of it, and
nothing that stops reading as a background once you have windows open. It is
drawn from the palette, accent and gaps you already chose, which is why it can
be the default.
`style = "none"` is a real answer rather than an absence: nothing runs, and
whatever wallpaper you have stays exactly where it is. The installer asks.
| Knob | |
| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `haus.wallpaper.depth` | how far in from the palette's outermost tone the field sits, `0`–`5`. `1` (default) is one rung inside black in a dark config and inside white in a light one: a step below what your terminal draws on, which is what keeps a whole screen of it reading as a wall. `haus.wallpaper.background` overrides it with a hex. |
| `haus.wallpaper.mark.*` | `enable`, `size`, `weight`, `opacity`, `rise`, and `color`: `spectrum` (default), `muted`, `ink` or `accent`. `spectrum` is the six-product sweep the ⌂ wears on [hausfold.co](https://hausfold.co), held still. |
| `haus.wallpaper.glow.*` | the bloom: `color` (your accent), `strength`, `spread`. |
| `haus.wallpaper.grain` | film grain, which is what stops the bloom banding into contour rings. `0` turns it off, and you only want that with `glow.enable = false`. |
| `haus.wallpaper.size` | the pixel size to render at. Set it to your display's **native** count (`system_profiler SPDisplaysDataType` prints it); anything else is resampled, and resampling undoes the dither. |
| `haus.wallpaper.debug.enable` | off by default: prints which revision of each family repo this machine was built from, small and dim at the bottom left, at exactly the inset a tiled window's corner lands on, so it only ever surfaces on a bare desktop. |
Preview any of it without rebuilding a Mac:
```sh
nix build github:hausfold/haus#wallpaper && open result
```
## Apps you added yourself [#apps-you-added-yourself]
Everything above covers the tools haus installs. Apps **you** put in your
Apps you [added yourself](/docs/haus/rooms/apps) get the same treatment, as long as the
id you keyed the entry under matches a nebelung port:
```nix
haus.roster.zed = { key = "x"; name = "Zed"; appId = "dev.zed.Zed"; cask = "zed"; };
```
On the next rebuild its theme is written where that app looks
(`~/.config/zed/themes/`) in your current flavor, contrast and accent, and
re-written whenever you change them. The key is the **roster id**, not the display name: `zed`, not
`zed-editor`. `haus.theme.ports.enable = false` turns the whole pass off.
Dropping a file is enough for apps that read a fixed path. Plenty of others
(Xcode, Warp, OBS, JetBrains) have no file interface for *choosing* a theme, so
the file is placed and the one-time pick stays yours. A few (Slack, Raycast)
can't be installed by file at all. Rather than leave you to discover that months
later, `haus doctor` says where each one stands:
```
🌫 Nebelung theme
✓ gitui — themed (~/.config/gitui/theme.ron)
ⓘ Xcode — placed at ~/Library/…/Catppuccin Mocha.xccolortheme — pick it in the app's settings
ⓘ Slack — not installed — copy the hex string → Slack ▸ Preferences ▸ Themes ▸ paste
```
A ✓ is live now; a ⓘ tells you the one move left. Ports that would need merging
into a config file you own (VS Code's `settings.json`) are **listed but never
written**: silently half-editing your file is worse than telling you what to do.
nebelung's [ports table](https://github.com/hausfold/nebelung/blob/main/docs/ports.md)
publishes which is which.
## Obsidian [#obsidian]
Obsidian keeps its theme *inside each vault*, so haus doesn't guess which
folders are yours. List them as home-relative paths:
```nix
haus.hearth.obsidianVaults = [
"Library/Mobile Documents/iCloud~md~obsidian/Documents/notes"
];
```
Each listed vault gets the generated theme and has it selected; the rest of its
appearance settings are preserved. A path without an `.obsidian` directory is
skipped with a warning, so a typo can't create an empty lookalike vault.
Obsidian is one of the places light mode doesn't reach yet; it stays dark.
## Using nebelung on its own [#using-nebelung-on-its-own]
nebelung is a standalone colour system; you can theme any tool without the rest
of haus. Copy a rendered theme out of the flake's output tree, or consume the
flake:
```nix
inputs.nebelung.url = "github:hausfold/nebelung";
# rendered themes: ${nebelung.packages.${system}.default}//...
# raw palette: nebelung.palette # name → "#hex"
```
glow, lazygit, yazi and the browser render all fourteen accents as separate
files; the other tools ship one theme with the default accent baked in. Every
hex, and an interactive preview of all four variants, is in the
[nebelung repo](https://github.com/hausfold/nebelung).
Colours are generated at **build time** from that one source rather than loaded
from loose theme files, which is why the palette can never drift out of sync:
update nebelung, rebuild, and every tool gets the same new values at once.
Want a different fog? The palette is computed in [OKLCH](https://oklch.com/)
from three knobs (the grey's hue and chroma, and how far the accents are
calmed), so hue and saturation can be rewritten while perceptual lightness is
preserved. That is a change in the nebelung repo, not in your host file.
## Works with [#works-with]
* **[Displays](/docs/haus/rooms/displays)**: `haus.appearance.largePrint` is
one line for bigger type: it moves the interface scale, the contrast, macOS's
own contrast lift and `displays.main.uiScale` together, each as a default you
can still pin by hand.
* **[Apps](/docs/haus/rooms/apps)**: anything you add whose id nebelung has a
port for is themed too.
* Every other room takes its colours from here rather than declaring its own.
## Permissions and side effects [#permissions-and-side-effects]
Following macOS's own Light/Dark needs an **Automation** grant for whatever runs
your rebuild (System Settings ▸ Privacy & Security ▸ Automation), as
[light mode](#light-mode) describes. Theme *ports* write files into apps haus
did not install; that's the point of them, and
`haus.theme.ports.enable = false` stops it.
## Remove it [#remove-it]
The room has no off switch, because a machine with no colours is not a thing
anyone wants. The parts do:
```nix
haus.theme.ports.enable = false; # stop writing themes into other apps
haus.wallpaper.style = "none"; # keep the desktop picture you had
haus.appearance.largePrint = false;
```
## Options [#options]
Every setting, with types and defaults:
[Appearance](/docs/haus/reference/options#appearance).
# Apps (/docs/haus/rooms/apps)
Sooner or later you want Slack, or Discord, or `ripgrep`. Everything the machine
has goes in **one list**, `haus.roster`, and you never choose an *option*, only
which **fields** to set. A field says where the app comes from; another says what
key launches it; an entry with neither is still a declaration.
The quickest route doesn't involve editing anything: open pounce, run **Install
App**, and pick from the curated shelf or search Homebrew, the Mac App Store, or
your flake's pinned nixpkgs. It writes a small Nix module under
`hosts//packages/`, rebuilds, and commits that one file. There is no
`roster.json` behind it, just the same options you'd have written by hand.
By hand:
```sh
haus edit # opens ~/.config/nix/hosts//default.nix
haus rebuild # build, then switch
```
## Enable it [#enable-it]
The Apps room is always present: the roster is a list, and an empty one is a
machine with nothing extra on it. What you switch on are the curated extras:
```nix
haus.apps.videoPlayer.enable = true; # IINA, and the file types it claims
haus.apps.packs.writing.enable = true; # a saved collection, in one line
haus.appStore.install = true; # let a rebuild install appStoreId entries
```
## Where it comes from [#where-it-comes-from]
Four source fields, one per package manager. Set **the one** that applies;
setting two is a build error:
| Field | Installs from | Example |
| ------------ | --------------------------- | ------------------------- |
| `cask` | a Homebrew cask | `cask = "slack";` |
| `brew` | a Homebrew formula | `brew = "ical-buddy";` |
| `package` | your flake's pinned nixpkgs | `package = pkgs.ripgrep;` |
| `appStoreId` | the Mac App Store | `appStoreId = 497799835;` |
Set none of them and the entry is pure metadata: right for something already on
the machine (Safari, Music) that you still want a launcher key for.
Don't know a name? `brew search --cask obsidian`, or
`nix search nixpkgs ripgrep`.
## An app you'll live in [#an-app-youll-live-in]
A keyed roster entry gives an app a Caps-Lock letter, a cheatsheet row, and the
install. A [workspace](/docs/haus/rooms/windows#workspaces-and-what-lives-on-them)
entry naming it gives it a workspace, a bar pill, and the rule that sends its
windows there:
```nix
haus.roster.slack = {
key = "s";
name = "Slack";
appId = "com.tinyspeck.slackmacgap";
cask = "slack";
};
haus.workspaces.S = {
key = "s";
icon = ":slack:";
apps = [ "slack" ];
};
```
Rebuild, and ⇪ then S lands you on Slack, on workspace
`S`, every time.
Two fields everybody looks up:
* **`appId`** is the bundle id, which is how windows get sent to the right
workspace. `osascript -e 'id of app "Slack"'` prints it.
* **`icon`** goes on the *workspace*, not the app: a ligature from the
[SketchyBar app font](https://github.com/kvndrsslr/sketchybar-app-font), like
`:slack:`. Leave it out and the pill shows the workspace id.
Keeping them apart is what lets one workspace hold several apps (a `comms`
space with Slack, Mail and Messages) instead of one app per workspace.
## An app you just want installed [#an-app-you-just-want-installed]
Omit the launcher fields. No letter, no cheatsheet row, no pill, but still
**declared**, which is what keeps a later Homebrew cleanup from removing it:
```nix
haus.roster = {
discord = { name = "Discord"; cask = "discord"; };
figma = { name = "Figma"; cask = "figma"; };
};
```
Casks are installed *by* the rebuild but aren't in Nix generations, so `haus
rollback` neither uninstalls an app you added nor restores one you removed.
Deleting the line doesn't delete the app either; `haus.homebrew.cleanup` is
`"none"` on purpose. To actually remove one: `brew uninstall --zap `.
## An App Store app [#an-app-store-app]
Recording the id is always safe; **installing** from it is opt-in, because it
reaches the network and acts on your Apple Account:
```nix
haus.roster.xcode = {
name = "Xcode";
appStoreId = 497799835; # the digits in its App Store URL
};
haus.appStore.install = true;
```
Three limits worth knowing before you rely on it. `mas` **cannot sign in**; do
that once in App Store.app. It **can never buy a paid app**; buy it once, then
any machine can install it. It **can** fetch a free app you've never had. An
unpurchased paid app is reported and skipped during a rebuild, never fatal.
A rebuild fetches these during activation, which is already running as root, so
`mas get` neither prompts nor wedges. Going through pounce's **Install App**
instead runs it in a visible floating terminal, because that path can be asked
for an Apple Account.
## A CLI tool [#a-cli-tool]
Same list, no launcher fields:
```nix
haus.roster = {
ripgrep = { package = pkgs.ripgrep; };
ical-buddy = { brew = "ical-buddy"; }; # when nixpkgs hasn't got it
};
```
`scope` applies to `package` only, and it is about **reach**, not privileges:
* **`"user"`** (default): your profile. Right for anything you run as yourself.
* **`"system"`**: machine-wide, so it's on `PATH` for root, launchd jobs and
non-login shells. What a tool called by a daemon actually needs.
To *try* something without installing it, hearth wires up `comma`:
```sh
, cowsay "hello fog" # fetches it, runs it, keeps nothing
```
Reach for it twice and it has earned a roster entry.
## Every field [#every-field]
| Field | What it's for |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key` | The launcher letter, unique across the roster, and not one of launch mode's own: `V` `E` `Z` `,` `` ` `` `-` `=` `/` `1`–`4` `Esc` and the arrows are taken, and a rebuild refuses them. `null` (default) makes the entry install-only. |
| `name` | The macOS app name, which is what `open -a` uses. Required when `key` is set. |
| `appId` | The bundle id: auto-assigns windows to a workspace, and matches `float`. |
| `label` | The cheatsheet caption. `null` uses `name`. |
| `float` | Keep this app's windows floating instead of tiled, for a picker or status window that would reflow the workspace every time it opens. Needs `appId`. |
| `titleRegex` | Scopes `float` to matching window titles instead of every window. |
| `cask` · `brew` · `package` · `appStoreId` | Where it comes from. One of them, or none. |
| `scope` | `"user"` (default) or `"system"`, for `package` only. |
| `order` | Lower numbers appear earlier. |
| `enable` | `false` disables a default or imported entry by id. |
| `installedBy` | Set by haus, not by you: for an app copied in by its own activation step, which no source field can describe. |
## haus's own apps are entries too [#hauss-own-apps-are-entries-too]
You don't have to know what haus already installs. Each room declares its own:
prowl brings AeroSpace, sill brings SketchyBar, den brings Ghostty. So giving one
of them a launcher key means writing only the launcher half:
```nix
# perch's name, bundle id and installer come from haus.perch.
haus.roster.perch.key = "p";
```
The rule underneath: whoever installs an app owns its source fields, whoever
gives it a key owns its launcher fields. Two modules never fight over one field,
and your host file never restates what haus is doing.
A handful of apps arrive because haus *chose* them for you rather than because a
room needs them; those live in `haus.apps`, each behind one switch:
```nix
haus.apps.videoPlayer.enable = false; # don't install IINA
haus.apps.videoPlayer.claimFileTypes = false; # install it, rebind nothing
```
What it installs is an ordinary roster entry, so you can key it like any other.
File-type claims are the same user default Finder's *Get Info ▸ Change All*
writes, so nothing there is hard to undo. But **only one haus-owned app may
claim a type**, or macOS asks you to pick a winner on every rebuild. If you add
an extension to one list, check it against the other by UTI, not by spelling.
If the id you key the entry under matches a tool nebelung has a port for (`zed`,
`warp`, `xcode`, `gitui`, `kitty` and a few dozen more), its theme lands where
that app looks for one on the next rebuild, in your flavor, contrast and accent.
See [the Appearance room](/docs/haus/rooms/appearance#apps-you-added-yourself).
## Then apply it [#then-apply-it]
`haus rebuild` builds first, so a typo can't take down a running system. New
casks install, new tools land on `PATH`, and a new roster app gets its letter,
workspace and pill together. If it doesn't tile on first launch, tap
⇪ then \` to re-sort everything.
Your host file is a plain nix-darwin module: anything nix-darwin or
home-manager accepts works there and merges with haus. The roster is the common
case, not the only door; [Making it
yours](/docs/haus/desktops/customizing) is the rest of what that file can say.
## Works with [#works-with]
* **[Windows](/docs/haus/rooms/windows)**: a roster id named in a workspace
gets that workspace's pill and the rule that sends its windows there.
* **[Launcher](/docs/haus/rooms/launcher)**: **Install App** writes a roster
entry for you and rebuilds, so the common case needs no editor.
* **[Appearance](/docs/haus/rooms/appearance)**: an app whose id nebelung has a
port for is themed on the next rebuild.
## Permissions and side effects [#permissions-and-side-effects]
`haus.appStore.install` is off by default: it reaches the network and acts on
your Apple Account, and it can never be complete: `mas` cannot sign in and
cannot buy a paid app.
What a rebuild does to apps you *didn't* declare is
[`haus.homebrew.cleanup`](/docs/haus/desktops/customizing#homebrew-behaviour),
`"none"` by default, so nothing you installed by hand is ever removed.
## Remove it [#remove-it]
Delete the roster entry and rebuild. Whether the app itself goes depends on that
same `cleanup` policy: with the default `"none"` it stays installed and simply
stops being declared. A pack switches off as one line.
## Options [#options]
Every setting, with types and defaults:
[Apps](/docs/haus/reference/options#apps).
# Bar (/docs/haus/rooms/bar)
**sill** replaces the macOS menu bar with a scriptable status bar, painted in
your palette and wired to your tiling. While it's on, the native menu bar is
hidden; the bar is the one place to glance.
## Enable it [#enable-it]
```nix
haus.sill.enable = true;
```
## What's on it [#whats-on-it]
**Far left: the house.** Where macOS puts the Apple menu. It opens a menu,
and its colour tells you whether the machine is fine. See
[the logo pill](#the-logo-pill).
**Left: your workspaces.** One pill each, showing the workspace's id or its
icon, with the focused one highlighted and the front app's title alongside.
Switch workspaces and the highlight follows; tap the Caps-Lock leader and the
whole left side flips into launcher letters.
**Right: the status cluster.** Clock, battery, Wi-Fi, weather and media, all
on by default and each one hideable. More readouts are available and off by
default; see [toggling pills](#toggling-pills).
## Dropdowns [#dropdowns]
Several pills open a dropdown on click: weather, the calendar's timeline, the
coffee pill's durations, the cpu and memory breakdowns. They behave like every
other menu on the Mac: click elsewhere and it closes, click the pill again and
it closes, open a second one and the first closes.
The media pill is the exception: its left click is play/pause, so its dropdown
is on **right**-click.
## The logo pill [#the-logo-pill]
### It opens a menu [#it-opens-a-menu]
| Gesture | What you get |
| --------------- | --------------------------------------------------------------------------- |
| **Click** | System Settings, Activity Monitor, Lock Screen, your config, Rebuild System |
| **⌘ click** | `haus rebuild`, in a floating terminal |
| **Right click** | The full palette, the same thing ⌘Space opens |
Every row of that menu *is* a palette command, so anything you can click you
can also type. `haus.sill.logo.gestures = false` turns all three off, so the
pill is plainly not a button rather than a button that does nothing.
### It tells you the machine is fine [#it-tells-you-the-machine-is-fine]
| Colour | Meaning |
| ----------- | ------------------------------------------- |
| Your accent | Everything is running |
| Yellow | A newer version is available: `haus update` |
| Red | Something is enabled but **not running** |
Red is the one worth having. A wedged background agent is otherwise invisible:
tiling stops but leaves every window where it was, and a dead bar keeps the
last frame it drew on screen. The check is local, free, and runs every five
minutes.
The **yellow** half asks GitHub whether a new version exists (the only thing
this pill does that leaves your machine), so it's **off by default**. Turn it
on with `haus.sill.logo.updateCheck = true`.
Resting the pointer on the pill sweeps it through the six hausfold accents. It
only runs at the pill's resting colour: a pill that has gone yellow or red has
something to say and doesn't also get a rainbow. `haus.sill.logo.sweep = false`
if you'd rather it sat still.
### Changing the glyph [#changing-the-glyph]
```nix
haus.sill.logo.icon = "⌂";
haus.sill.logo.size = 25; # ⌂ is drawn thin; it needs the extra points
haus.sill.logo.color = "teal";
```
Left alone, the colour follows `haus.theme.accent`. The default is a solid
Nerd Font house at 20pt. Whatever you pick has to hold up in a 28pt pill, which rules out more than
you'd expect; the options reference lists six that work. You can't point it at
an image file: a picture couldn't follow your accent or show any of the states
above.
## The media pill [#the-media-pill]
It reads the same system-wide session Control Center does, so it follows a
browser tab as readily as Apple Music, and it hides itself entirely when
nothing is playing.
| Gesture | What it does |
| ----------- | ------------------------------------------ |
| Left click | Play / pause |
| Right click | The dropdown |
| ⌥ click | Next track |
| ⇧ click | Previous track |
| **⌘ click** | **Bring the app making the sound forward** |
| Scroll | Seek ±10s |
| Hover | Sweep a long title, once |
That ⌘ click is the one to remember: it's the answer to *something is making
noise and I can't find the tab*.
Two habits. A long title never scrolls on its own, only on hover, and a sweep
always finishes once started. And anything over twenty minutes counts **down**
(`-38m`) instead of scrolling a name you already know.
The dropdown carries cover art, title, artist, album, and a position slider you
can click to scrub.
```nix
haus.sill.media.collapse = true; # glyph only until you hover, good on a notched Mac
haus.sill.media.width = 16; # characters of title before it sweeps. 25 by default.
```
`width` is a maximum, not a fixed size: a short title still draws a short pill.
## Toggling pills [#toggling-pills]
Every right-side pill is one boolean. The five core pills (`clock`, `weather`,
`media`, `battery`, `wifi`) default **on**; everything else defaults off. Set
only what you want to change:
```nix
haus.sill.items = {
weather = false; # drop a core pill
cpu = true; # add an off-by-default readout
caffeinate = true;
};
```
A pill set `false` is never created; its update script doesn't even run.
### The extra readouts [#the-extra-readouts]
| Pill | What it is |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `cpu` | Load, drawn as a graph of the last two minutes. Hover splits user vs system; click lists what's responsible, per app; right-click opens Activity Monitor. |
| `memory` | Memory in use, as a graph. Coloured by the kernel's own pressure level, not the percentage: 60% in use is a Mac working correctly. |
| `volume` | Output volume and mute state. |
| `calendar` | Your next meeting and one gesture to join it. Reads `in 12m · Design review`; the whole pill fills five minutes either side of the start. **Right-click joins** the conferencing link; left-click opens the day as a timeline. |
| `caffeinate` | A coffee pill: click for 1, 2, 4, 8 hours or until stopped; right-click stops. Survives a bar reload. |
| `agents` | Tracks your [agent](/docs/haus/rooms/ai) panes: amber when one is waiting on you, click to jump to it. |
| `aiUsage` | How much of your AI usage you've spent, as a gauge. Click for every provider's windows and reset times. |
| `elgato` | Toggles an Elgato Key Light on your network. |
| `harvest` | Harvest time tracking. Reads credentials from a file you provide. |
The keep-awake engine is also a command, with or without the pill:
```sh
awake 3h
awake indefinitely
awake off
```
The **hush** (Do Not Disturb) pill is separate: whether it exists is
`haus.hush.enable`, not `haus.sill.items`. Where it goes is still a bar
setting, which is why it can appear in `haus.sill.bottom.items` below.
sill never bakes a token into the build. The Harvest pill sources your own
credentials file at runtime; `aiUsage` reads your existing Codex login the same
way. Keep those files out of git.
## A second bar along the bottom [#a-second-bar-along-the-bottom]
If you want more room, or you'd rather keep the readouts off the menu bar
entirely, sill can draw a second bar along the bottom of the screen at the
same time:
```nix
haus.sill.bottom = {
enable = true;
items = { weather = "left"; media = "center"; hush = "right"; };
};
```
A pill named here **moves**: it's drawn on the bottom bar and not the top one,
whatever `haus.sill.items` says. Never two copies racing each other.
The value is where it goes: `"left"`, `"center"` or `"right"`; `true` means
`"right"`. The menu bar only has one usable group (its left is the workspaces,
its middle is under the notch on a MacBook), so the bottom strip is where all
three are yours.
Three things before you turn it on:
* **macOS reserves nothing at the bottom of the screen.** Tiling carves the
room out instead, so with tiling off, windows will run underneath it.
* **The Dock shares that edge.** Move it to a side, or hide it.
* **Don't put both bars on the same edge.** `haus.sill.position = "bottom"`
plus a second bar is two bars in one place; you'll get a warning on rebuild.
## Turning it off [#turning-it-off]
```nix
haus.sill.enable = false;
```
The native menu bar comes back, and the bottom bar goes with it. Tiling and
everything else are unaffected.
## Works with [#works-with]
Every pill that isn't the bar's own comes from another room, and appears only
when that room is on:
* **[Windows](/docs/haus/rooms/windows)**: the workspace pills and the leader's
letter hints;
* **[Focus](/docs/haus/rooms/focus)**: the quiet switch's pill;
* **[AI](/docs/haus/rooms/ai)**: the agents readout, amber when a session is
waiting on you;
* **[Launcher](/docs/haus/rooms/launcher)**: what a click on the logo pill can
reach.
A missing room removes its pill and nothing else.
## Options [#options]
Every setting, with types and defaults:
[Bar](/docs/haus/reference/options#bar).
# Development (/docs/haus/rooms/development)
**hearth** is the terminal room. It sets up the whole command line (emulator,
multiplexer, shell, prompt, file manager, editor, and a toolbelt of modern
replacements for the usual commands), painted from the same
[palette](/docs/haus/rooms/appearance), down to the default editor.
| | | |
| ----------- | ----------------------------------------------- | ---------------------------------------------------------------------------- |
| Terminal | [Ghostty](https://ghostty.org) | title bar hidden, opens straight into the multiplexer |
| Multiplexer | [zellij](https://zellij.dev) | custom tab bar, three swap layouts |
| Shell | zsh | no framework, fzf-tab, sensible history, git aliases |
| Prompt | [starship](https://starship.rs) | |
| Files | [yazi](https://yazi-rs.github.io) | markdown, code and image previews |
| Editor | [helix](https://helix-editor.com) | the default `$EDITOR`, or neovim, vim, nano ([below](#choosing-your-editor)) |
| Jumper | [zoxide](https://github.com/ajeetdsouza/zoxide) | `cd` learns your habits; `cdi` opens a picker |
Plus `bat` over `cat`, `lsd` over `ls`, `lazygit` (`lg`), `delta` for git diffs,
and `fzf`, `fd`, `jq`, `gh`, `glow`, `fastfetch`, with `nix-index` and `comma`
so you can run anything else in nixpkgs without installing it.
## Enable it [#enable-it]
```nix
haus.developer.enable = true;
haus.developer.languages = [ "node" ];
```
The terminal stack itself (Ghostty, zellij, zsh, the prompt, the editor) is
part of the foundation and always there. `haus.developer.*` is what brings the
*tools*: the CLI toolbelt, the Git tooling and the language runtimes, each with
a sub-switch of its own (`.toolbelt`, `.git`, `.languages`).
## Ghostty opens into zellij [#ghostty-opens-into-zellij]
Launching the terminal drops you into a persistent session called `main`; new
panes and tabs inherit your working directory. Ghostty's own `⌘T`, `⌘P`, `⌘Y`,
`⌘W` and friends are **unbound** on purpose, so those keys mean the same thing
whether or not you're multiplexed. (`⌘C` is not one of them; it stayed with
Ghostty, as copy. Day to day you won't press it, since zellij copies a mouse
selection the moment you release it, but a `⇧`-drag makes a Ghostty-level
selection and `⌘C` takes it.)
Two of the unbindings are fixes rather than hand-offs:
* **`⌘W`.** Ghostty's default there closes the whole surface, and with close
confirmation off a stray `⌘W` silently took the window and the session in it.
Now it closes one pane; `⌘⇧W` still closes the window.
* **`⌘D` / `⌘⇧D` do nothing**, on purpose. Their Ghostty defaults nest a second
terminal *inside* the one zellij is driving: invisible to the multiplexer, so
its layout and pane keys would apply to half the window. Use `⌘P`.
| Keys | What happens |
| ----------------- | ----------------------------------------------------------------------------------------------------------------- |
| `⌘P` / `⌘⇧P` | New pane. The plain one hops to the repo's **main checkout** when you're inside an agent worktree; `⇧` stays put. |
| `⌘T` / `⌘⇧T` | New tab at `$HOME` / at the focused pane's directory. Fresh tabs are born named `~`, never `Tab #N`. |
| `⌘W` | Close the focused pane (and the tab with it, when it was the last one) |
| `⌘F` / `⌘⇧F` | [Find](#finding-things) in this pane / across every pane |
| `⌘G` | Your GitHub review queue, if you turned it on |
| `⌘⏎` | Fullscreen the focused pane, or Ctrl-click the pane body |
| `⌘Y` / `⌘⇧Y` | [yazi peek](#yazi-peek), with and without the worktree hop |
| `⌘L` | Links picker: every URL in the pane's transcript or scrollback |
| `⌘A` / `⌘⇧A` | Spawn a coding agent in its own worktree, beside the focused pane or in place of it |
| `⌃⌥⇧A` | Spawn one in **this** checkout instead of its own worktree |
| `Ctrl Tab` | Walk tabs in most-recently-used order, browser-style |
| `Alt [` / `Alt ]` | Cycle layouts: grid → spiral → columns |
**An agent worktree** is the throwaway checkout `⌘A` puts a coding agent in: its
own copy of the repo on its own branch, so several agents can work at once
without fighting over one set of files. Half the rows above have a `⇧` variant
because of it: the plain key assumes you want the *real* repo, and `⇧` says stay
in the copy. The [AI room](/docs/haus/rooms/ai) has the rest of the
lifecycle: resuming one, setting work aside, cleaning up.
**Where that agent's terminal lives is this room's call**, though, and
`haus.hearth.lanes.backend` is the switch. `"zellij"`, the default, is what the
rows above describe: the lane is a pane in the `main` session. `"zmx"` makes it
its own [zmx](https://github.com/neurosnap/zmx) session in its own Ghostty
window, tiled by prowl. The session outlives every client attached to it, so
`⌘W` detaches and the agent keeps thinking, and reopening the lane rejoins the
live conversation instead of replaying a transcript. The trade is splits: zmx
has none by design, so prowl tiles real windows rather than a second window
manager nested inside a terminal.
**zellij boots Locked.** Its single-key submode leaders stay inert until you
press `Ctrl g`, so a stray keystroke can't drop you into resize mode. Everything
in the table above works regardless. `haus.hearth.zellijStartLocked` flips it.
There is **no reload chord**, because there is nothing to reload: zellij watches
its own config and applies a change to the running server within a second. A
`haus rebuild` lands on the session you already have: same tabs, same panes,
same directories, live agent conversations uninterrupted.
A plain click on a file path opens it **in your editor, at its line number**
(`src/main.rs:42` lands on line 42), and a click on a directory opens a new tab
already `cd`'d there. Handy for whatever a build tool just printed. Links work
in every pane, including ones running a TUI that has taken the mouse; paths work
in any pane no program is tracking the mouse in.
## Finding things [#finding-things]
`⌘F` opens a search overlay for the focused pane, `⌘⇧F` the same overlay across
every pane. It covers the window but is still a floating pane, so `Esc` puts the
tiled layout straight back. Results appear as you type, with the lines around
each hit beside them. `⏎` jumps to the pane a hit came from, `^y` copies the
line, `^s` flips between this-pane and every-pane without losing your query.
**In an agent pane it searches the conversation, not the terminal.** Claude Code
and Opencode panes render in the alt-screen, which has no scrollback at all, so
searching the grid would only ever find what's on screen this second. Searching
the stored conversation finds all of it, including text inside collapsed tool
output. Codex panes fall back to scrollback; they report their pane state like
the others but carry no conversation id to join to. Every other pane gets its
full scrollback.
zellij's own in-place search is still there, for when you want matches
highlighted in the real pane and `n`/`p` to walk them: `Ctrl g` to unlock, then
`s`. Its exits return you to Locked rather than Normal.
## Your review queue [#your-review-queue]
`⌘G` opens [gh-dash](https://github.com/dlvhdr/gh-dash) as a borderless overlay,
themed like everything else and gone again on `q`. It's off unless you ask:
```nix
haus.hearth.ghDash.enable = true;
haus.git.org = "your-org"; # or your own account
```
The first line gets you the tabs that are about *you*: issues you opened, ones
assigned to you, unread notifications, threads you're in. The second is what a
PR tab needs, because a PR tab is a GitHub search and a search needs a scope.
Set it and four more appear: **open**, **green** and **red** (the same queue
split by whether CI passed; together they *are* the merge decision, and a
branch still building shows in neither), and **shipped**.
Leave `haus.git.org` empty (right if you read several owners at once) and
those four simply aren't written.
## yazi peek [#yazi-peek]
`⌘Y` opens a floating file browser with live previews, sized to cover the
terminal window that summoned it exactly, so it reads as that window turning
into a file browser rather than a popup landing on it. `Enter` on a file pages
it fullscreen; `Enter` on a directory opens a new zellij tab there, which makes
peek double as a browse-and-pick tab chooser. `q` or `Esc` closes it and leaves
your layout untouched.
Markdown renders through `glow`, code through `bat`, images through `chafa`.
`Y` copies a file's *contents*, not its path.
Every floating terminal haus summons wears a thin outline that follows the
window's corner curve, so a dark terminal on a dark window still has an edge:
```nix
haus.hearth.floatBorder = "accent"; # the default; also "grey", any accent name, or "off"
```
## Git aliases [#git-aliases]
Plain zsh, no framework, and a finite set of shortcuts. The names follow the
widely-used Oh-My-Zsh vocabulary where conventions agree, and deliberately skip
the ambiguous ones (`gl`, `gr`, `gs`) whose meanings differ between alias
sets. `g` itself is `git`.
| | |
| ----------------- | ----------------------------------------------------------- |
| Add | `ga` `gaa` `gapa` |
| Branch / checkout | `gb` `gba` `gbd` `gbD` `gbm` `gco` `gcb` `gcl` `gsw` `gswc` |
| Commit | `gc` `gca` `gcam` `gcmsg` `gcn` `gcp` `gcpa` `gcpc` |
| Diff / log | `gd` `gds` `gdw` `glo` `glog` `gloga` |
| Fetch / merge | `gf` `gfa` `gfo` `gm` `gma` `gmc` `gmff` |
| Pull / push | `gpl` `gpr` `gp` `gpf` `gpsup` |
| Rebase | `grb` `grba` `grbc` `grbi` `grbs` `grt` `grv` |
| Status / stash | `gst` `gss` `gsb` `gsta` `gstl` `gstp` `gsts` |
| Tag / worktree | `gt` `gwt` `gwta` `gwtl` `gwtr` |
Add, replace or remove one per host:
```nix
haus.git.shellAliases = {
gsync = "git pull --rebase --autostash"; # add
gst = "git status --short --branch"; # replace
gco = null; # remove
};
```
## Choosing your editor [#choosing-your-editor]
Name one and the room installs it. `haus.hearth.editorName` takes **helix** (the
default), **neovim**, **vim** or **nano**: one choice that installs the editor,
sets your `$EDITOR`, and points every "open in an editor" action at it. Those
open in a new zellij tab, which is why the list is terminal editors.
```nix
haus.hearth.editorName = "neovim"; # installed, and $EDITOR
haus.hearth.hijackFileAssociations = true; # off by default: make it the
# opener for .json, .md, .nix, …
```
helix is the one haus paints from the palette: Nebelung has a theme for it and
none for the others, so the alternatives arrive in their own default colours.
To point haus at an editor it does **not** install (a GUI one, or something
your host installs itself), set `haus.hearth.editor` instead. That one is a
shell command haus runs, so it belongs in your host file rather than in a shared
desktop, and it wins over whatever `editorName` chose:
```nix
haus.hearth.editor = "code -w"; # points at it; installs nothing
```
## Smaller things you'll notice [#smaller-things-youll-notice]
* **Tabs rename themselves.** `cd` into a repo and the tab takes its name; `cd ~` puts it back to `~`.
* **Agent-worktree aware.** A new shell opened inside an agent worktree hops to
the parent repo, and the tab is named after the real repo rather than the
throwaway checkout. `⌘⇧P` is the no-hop escape hatch.
* **`Ctrl Tab` really works**, which it usually doesn't on macOS: Ghostty
forwards the kitty-protocol encoding so zellij sees it.
## Works with [#works-with]
* **[AI](/docs/haus/rooms/ai)**: with both rooms on, agent panes get their
zellij bindings and the worktree lifecycle around them.
* **[Appearance](/docs/haus/rooms/appearance)**: the whole stack is painted
from the same palette.
* **[Launcher](/docs/haus/rooms/launcher)**: commands that open a terminal land
in the same multiplexer you already live in.
## Remove it [#remove-it]
```nix
haus.developer.enable = false;
```
The toolchains, the Git tooling and the toolbelt go; the terminal, shell and
editor stay, because they are the floor rather than the room. Take out one piece
at a time with `haus.developer.git.enable`, `.toolbelt.enable` or an empty
`.languages`.
## Options [#options]
Every setting, with types and defaults:
[Development](/docs/haus/reference/options#development).
# Displays (/docs/haus/rooms/displays)
The Displays room is how haus makes the **whole Mac** bigger or smaller, apps it
knows nothing about included. `haus.ui.scale` and `haus.fonts` resize haus's own
surfaces; display scaling changes what a point means everywhere.
It touches nothing until you name a screen. There is no room switch: an empty
`haus.displays` is a machine whose displays are left exactly as macOS has them.
## Enable it [#enable-it]
```nix
haus.displays.internal.uiScale = "larger-text";
```
```sh
haus rebuild
```
## Configure it [#configure-it]
Name the screen by what it **is**, not by what it's called today:
| Key | Which screen |
| ---------- | ---------------------------------------------------------------------------------- |
| `internal` | the built-in panel |
| `main` | whichever display is currently main |
| `` | one specific external monitor; `hausdisp list` prints the UUIDs of what's attached |
```nix
haus.displays = {
internal.uiScale = "larger-text";
"37D8832A-2D66-02CA-B9F7-8F30A301B230".uiScale = "more-space";
};
```
`uiScale` takes the same four positions System Settings ▸ Displays offers, named
as intents: `more-space`, `default`, `larger-text`, `largest-text`. Each is
resolved from the modes *that* panel actually reports, so the same value means
the same thing on a 14-inch laptop and a 27-inch monitor rather than the same
number of pixels.
When two selectors name the same attached panel, the more specific one wins:
UUID over `internal` over `main`. That's what lets a host-specific setting refine
a broad profile such as `haus.appearance.largePrint` without depending on
activation order.
An entry for the screen at the office is noted and passed over on the train. A
rebuild never fails because you left a monitor behind.
## Works with [#works-with]
* **[the Appearance room](/docs/haus/rooms/appearance)**: `haus.appearance.largePrint`
sets `displays.main.uiScale` as one of the four levers it pulls, alongside the
interface scale and the high-contrast palette. Naming a display yourself wins
over it.
## Permissions and side effects [#permissions-and-side-effects]
Scaling is applied at each home-manager activation and set **permanently**, so it
survives a reboot. Re-applying a mode that's already current is a no-op, so a
rebuild doesn't flash your screen.
A desktop may set `internal` and `main`; a **UUID is host-only**, because it
names one particular piece of hardware. A shared desktop that tried would be
refused by name.
## Remove it [#remove-it]
Delete the entry and rebuild. The display keeps whatever mode it last had: a
removed entry stops *managing* the screen rather than restoring an earlier
resolution, so set the position you want explicitly if you want it back.
## Options [#options]
Every setting, with types and defaults:
[`haus.displays`](/docs/haus/reference/options#displays).
# Focus (/docs/haus/rooms/focus)
**hush** is the quiet room. One switch (the bell pill in the bar, "Toggle
Hush" in the palette, or `hush` in a terminal) turns macOS **Do Not
Disturb** on or off, optionally sets your **Slack status** (and silences
Slack on your phone), and runs any **hooks** you add. All three surfaces
call the same engine, so they can never disagree.
Because hush drives the *real* macOS Focus, everything Focus already does
comes along free: with **Share Across Devices** on, your iPhone and iPad
hush too, and the "allowed to break through" list is whatever you've curated
in System Settings.
## Enable it [#enable-it]
```nix
haus.hush.enable = true;
```
## How the toggle works [#how-the-toggle-works]
Apple ships no public API or CLI to set a Focus; every "focus CLI" on
GitHub is secretly a Shortcuts wrapper. haus takes a different path: macOS
has a hidden keyboard shortcut slot (*"Turn Do Not Disturb On/Off,"*
symbolic hotkey **175**, disabled by default), and every rebuild binds it
declaratively to **⌃⌥⇧⌘ F13**, a chord nothing else uses. `hush` synthesizes
that keypress; nothing to author by hand, and the binding heals itself on
every rebuild.
State is read from the Focus database (`~/Library/DoNotDisturb/DB`) when the
calling app has Full Disk Access, so the pill stays truthful even when you
toggle Focus from Control Center or your phone; without that grant, hush
falls back to remembering its own last toggle.
## The one-time grants [#the-one-time-grants]
Synthesizing a keypress needs **Accessibility**; exact state reads need
**Full Disk Access**. Both are granted once, per app, in System Settings →
Privacy & Security. Run `hush doctor` any time to check each piece and print
the fix for whatever's missing.
* **Palette toggles work out of the box**: they run under the Pounce
daemon, which already holds Accessibility.
* **For the bar pill**, add **sketchybar** to Accessibility.
* **For the CLI**, add your terminal.
* **Optional but recommended**: add the signed **Pounce.app** (or your
terminal + sketchybar) to **Full Disk Access** so `hush status` reads the
real Focus state instead of guessing.
macOS keys TCC grants to a code-signing identity. haus re-signs Pounce with
your stable identity precisely so these checkboxes survive rebuilds: tick
them once, forget them.
## The Slack leg [#the-slack-leg]
macOS Focus already silences Slack banners *on your Mac*. The Slack leg does
what Focus can't: tells your **teammates** (status + 🔕) and silences your
**phone** (Slack's own DND, all devices). Your previous status is stashed
and restored on unhush.
It's off by default because it needs a personal Slack token:
1. Create a Slack app for yourself at [api.slack.com/apps](https://api.slack.com/apps)
("From scratch", any name, your workspace).
2. Under **OAuth & Permissions → User Token Scopes**, add
`users.profile:write` and `dnd:write`, then **Install to Workspace** and
copy the **User OAuth Token** (`xoxp-…`).
3. Stash it in your Keychain; no secret ever touches your config or the
Nix store:
```bash
security add-generic-password -s hush-slack -a "$USER" -w 'xoxp-…'
```
4. Point hush at it in your host file:
```nix
haus.hush.slack = {
enable = true;
tokenCommand = "security find-generic-password -s hush-slack -w";
statusText = "heads down";
statusEmoji = ":no_bell:";
snooze = true; # silence Slack push on all devices
};
```
`hush doctor` verifies the token against Slack's API once it's wired.
## Hooks: make hush yours [#hooks-make-hush-yours]
Every hush/unhush also runs your own scripts, called with `on` or `off`.
Failures are logged, never fatal:
```nix
haus.hush.hooks = [
./onair-light.sh # turn the Elgato red while hushed
"/Users/ada/bin/pause-music" # strings run as-is, so $HOME paths work
];
```
Ideas: pause the music, flip an on-air light, start a Harvest timer, shove
chatty apps to a parking workspace with `aerospace`.
## Honest scope [#honest-scope]
* hush flips the built-in **Do Not Disturb**, not named Focus modes; those
have no symbolic hotkey to bind.
* It doesn't manage **which apps or people break through**; that's your
Focus allowlist, curated once in System Settings → Focus.
* Without Full Disk Access, `hush status` is hush's own memory and can
drift if you toggle Focus elsewhere; it self-corrects on the next real
toggle either way.
## Troubleshooting [#troubleshooting]
**Run `hush doctor` first**: it checks the hotkey binding, the state-read
grant, and the Slack token, and prints the fix for each.
* **Click the pill, nothing happens**: sketchybar has no Accessibility
grant yet; toggle from the palette instead, which needs nothing.
* **Pill shows the wrong state after toggling from Control Center**: grant
Full Disk Access for exact reads.
* **The chord stopped working**: a rebuild re-binds hotkey 175 on every
activation and heals it.
## Works with [#works-with]
* **[Bar](/docs/haus/rooms/bar)**: the pill that shows the state and toggles it.
* **[Launcher](/docs/haus/rooms/launcher)**: the same toggle as a command.
Neither is required: with both rooms off, `hush` is still a CLI.
## Remove it [#remove-it]
```nix
haus.hush.enable = false;
```
The pill, the command and the CLI go. The Do Not Disturb chord this room adds is
one macOS ships disabled, so switching the room off leaves a spare binding
rather than taking one away, the opposite of the launcher's ⌘Space.
`haus.hush.slack.tokenCommand` is host-only: it names a secret on one machine.
## Options [#options]
Every setting, with types and defaults:
[Focus](/docs/haus/reference/options#focus).
# Launcher (/docs/haus/rooms/launcher)
The Launcher room installs **[pounce](/docs/pounce)**, a native command palette
where every command is a shell script in a folder. Hit **⌘Space**, fuzzy-type,
hit **Return**: apps, your own commands, clipboard history, an emoji grid and
inline answers, all in one list.
Pounce is free, MIT and installs standalone from Homebrew without any of this.
What the room adds is the wiring: the daemon as a login item, ⌘Space actually
free, and the one macOS permission surviving a rebuild.
## Enable it [#enable-it]
```nix
haus.pounce.enable = true;
haus.keys.palette = "cmd-space"; # or alt-space, ctrl-space, "none"
```
```sh
haus rebuild
```
Only `cmd-space` displaces Spotlight; moving the palette elsewhere is how you
keep both. haus frees ⌘Space from Spotlight so pounce can own it, which is the
step a standalone install has to do by hand.
## Configure it [#configure-it]
`config.json` is **generated** from `haus.pounce.*`, so `pounce config init`
refuses inside haus; the next rebuild would put the generated file straight
back. Change it in your host file instead, and the rebuild bounces the daemon
for you when one of the startup-only keys moves.
```nix
haus.pounce.windowSwitcher = true; # ⌘Tab by window; defaults on here
haus.pounce.autoQuit.enable = false; # quit an app when its last window closes
haus.pounce.items."mode:emoji".hotkey = null; # leave Fn/Globe native
```
`haus.ui.scale` writes pounce's `scale` for you, and your `haus.theme.flavor` /
`haus.theme.contrast` reach it as a palette file, so the palette matches the
rest of the house without a second theme setting.
```nix
haus.pounce.signingIdentity = "Apple Development: you@example.com (XXXXXXXXXX)";
```
A plain Nix rebuild is ad-hoc signed, and macOS ties an Accessibility grant to
a signature, so without this you re-approve the dialog after every rebuild.
Set it and the room re-signs a stable copy of the app with your identity, and
the grant persists.
It is **host-only**: it names a certificate on one machine, so a shared desktop
may not set it.
## Works with [#works-with]
* **[Apps](/docs/haus/rooms/apps)**: **Install App** writes a roster entry and
rebuilds.
* **[AI](/docs/haus/rooms/ai)**: **Spawn Agent** creates a named worktree and
opens a session in it.
* **[Focus](/docs/haus/rooms/focus)**: the quiet switch is a command here too.
* **[Windows](/docs/haus/rooms/windows)**: the opt-in `⌘Tab` window switcher,
which groups its rows by AeroSpace workspace.
Each command appears only when its room is on.
## Remove it [#remove-it]
```nix
haus.pounce.enable = false;
```
haus disables Spotlight's shortcut with a one-way write, and nothing re-enables
it. Put it back yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts.
## Options [#options]
Every setting, with types and defaults:
[Launcher](/docs/haus/reference/options#launcher).
# Security (/docs/haus/rooms/security)
**collar** is the small auth room: it turns on **Touch ID for `sudo`**, and
(the part stock Touch-ID-for-sudo gets wrong) makes that work **inside a
terminal multiplexer** like zellij or tmux, where it would otherwise silently
fail.
## Enable it [#enable-it]
```nix
haus.collar.enable = true;
haus.collar.passwordlessRebuild = true;
```
## What it turns on [#what-it-turns-on]
```nix
security.pam.services.sudo_local.touchIdAuth = true; # fingerprint for sudo
security.pam.services.sudo_local.reattach = true; # make it work in tmux/zellij
```
`reattach` pulls in [`pam_reattach`](https://github.com/fabianishere/pam_reattach),
which reattaches the sudo process to your GUI login session. Without it,
`sudo` inside a multiplexer detaches from Aqua and the Touch ID prompt just
beachballs. Cancel the prompt and it falls back to a typed password.
collar also installs `/etc/sudoers.d/darwin-rebuild`, exempting activation
from authentication entirely:
```
ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/darwin-rebuild
ALL=(ALL) NOPASSWD: /run/current-system/sw/bin/haus-activate
```
Activating a build is the one privileged thing this desktop does constantly,
and a prompt per rebuild buys nothing: you already authenticated to *build*
it. `darwin-rebuild` is the classic route (and the only one for `haus
rollback`); `haus-activate` is the faster path `haus rebuild` and `bench try
switch` take, building as you and handing the result to root without a
second evaluation. Neither is a wider grant than `darwin-rebuild switch --flake ` already was; `haus.collar.passwordlessRebuild = false`
drops both.
macOS 26.6 shipped sudo 1.9.17, which stopped dereferencing a command's
symlink before matching sudoers: a rule against a `/nix/store/…` path only
matches if you type that literal path, which nobody does. Matching
`/run/current-system/sw/bin/darwin-rebuild` is what survives a rebuild.
Type `sudo -v` in a zellij pane and the Touch ID dialog floats up right over
the multiplexer. Touch the sensor, no password typed. (`darwin-rebuild` is
the happy exception; it doesn't even ask.)
## What it does *not* do [#what-it-does-not-do]
collar stays out of key management. **YubiKey and GPG** live outside Nix
entirely: key material, smartcard setup, and `pinentry` are handled by
`gpg-agent` + `pinentry-mac` at runtime, keeping secrets off the Nix store.
Commit signing itself is wired up in [the Development room](/docs/haus/rooms/development)
from `haus.git.signingKey`; collar only handles `sudo` authentication.
## Two switches [#two-switches]
```nix
haus.collar.enable = true; # Touch ID for sudo, reattach and all
haus.collar.passwordlessRebuild = true; # activation never authenticates
```
Both default on. Turn `passwordlessRebuild` off on a machine you don't
administer alone, and activation goes back to asking. Turn `enable` off and
macOS's stock password prompt comes back everywhere.
## The rest of the room [#the-rest-of-the-room]
Three more surfaces, each unset by default so a fresh Mac keeps macOS's own
answer until you say otherwise:
```nix
haus.lock.requirePassword = true; # and .requirePasswordDelay
haus.security.firewall.enable = true; # the built-in application firewall
haus.secrets.provider = "keychain"; # where secret values come from
```
The firewall is off on a fresh Mac; it is the setting to turn on for a laptop
that joins networks you don't own. Secrets themselves never live in your
config; haus declares which exist and fetches them at runtime.
## Works with [#works-with]
* **[Development](/docs/haus/rooms/development)**: commit signing is configured
there; this room is what stops `sudo` asking for a password.
* **[AI](/docs/haus/rooms/ai)**: `passwordlessRebuild` is what lets an agent
run `haus rebuild` end to end without a prompt it can't answer.
## Remove it [#remove-it]
```nix
haus.collar.enable = false;
```
`sudo` goes back to the password prompt. The firewall and lock settings stop
being written but keep whatever value they last had: unsetting an option stops
managing a macOS setting rather than restoring the one before it.
`haus.secrets.provider` is host-only: where a machine keeps its secrets is a
fact about that machine.
## Options [#options]
Every setting, with types and defaults:
[Security](/docs/haus/reference/options#security).
# Shelf (/docs/haus/rooms/shelf)
You know the dance: drag a file, realise the window you wanted is buried under
four others, let go, dig it out, drag again. The Shelf room installs
**[perch](/docs/perch)**, which ends it: start dragging anything, flick up to
the notch, and a small shelf drops down to catch it. Pile in as much as you
like, from as many places as you like, then grab any tile and carry the whole
group to its real home in one motion.
This page is the **room**: the wiring, the one option, and what a rebuild does.
The app itself is documented in [its own tree](/docs/perch).
## Enable it [#enable-it]
```nix
haus.perch.enable = true;
```
```sh
haus rebuild
```
Perch is copied to `/Applications` and kept current with the rest of the house.
Standalone and in-the-house are the same app: if you already installed it with
`brew install --cask hausfold/tap/perch`, letting the room own it is the
upgrade.
## Configure it [#configure-it]
There is one setting, and it's about colour:
```nix
haus.perch.followSystemAppearance = true;
```
With it on, the shelf follows macOS Light/Dark itself (no rebuild, no relaunch)
at your `haus.theme.contrast`, rather than being pinned to
`haus.theme.flavor` like every other themed tool. Perch has no theme picker of
its own (a five-second surface has nowhere to put one), so this is the only word
on its colours.
## Works with [#works-with]
* **[the Appearance room](/docs/haus/rooms/appearance)**: the shelf takes your palette
and contrast, unless you hand the polarity to macOS with
`followSystemAppearance`.
## Permissions and side effects [#permissions-and-side-effects]
This room grants **nothing**, because perch asks for nothing: no Accessibility,
no Input Monitoring, no Screen Recording, no Dock icon, no telemetry. There is
no permission step in a rebuild for it, which is unusual enough here to be
worth saying.
One macOS setting fights it, and haus can't fix it for you: **System Settings ▸
Desktop & Dock ▸ "Drag windows to top of screen to enter Mission Control"**.
macOS arms that top-edge trigger for the whole of any drag, files included, and
it fires over the same band the shelf lives in. Leave it on and the Dock steals
the drop. [Install](/docs/perch/install) has it too, for a Mac without haus.
## Remove it [#remove-it]
```nix
haus.perch.enable = false;
```
The room stops installing and updating it. The copy already at
`/Applications/Perch.app` stays until you delete it; a rebuild never removes an
app from your Applications folder behind your back.
## Options [#options]
Every setting, with types and defaults:
[`haus.perch`](/docs/haus/reference/options#shelf).
# Text expansion (/docs/haus/rooms/text-expansion)
Type `@@` and get your email address. Type `;addr` and get your postal address.
The Text expansion room wires up [espanso](https://espanso.org) and keeps the
table of snippets in the same file as the rest of your machine.
It works where macOS's own text replacement doesn't (terminals and many
Electron apps), because espanso injects keystrokes rather than relying on the
system text engine.
## Enable it [#enable-it]
```nix
haus.snippets.enable = true;
```
```sh
haus rebuild
```
## Configure it [#configure-it]
The table is a list of `trigger` → `replace` pairs:
```nix
haus.snippets = {
enable = true;
matches = [
{ trigger = "@@"; replace = "ada@example.com"; }
{ trigger = ";sig"; replace = "— Ada"; }
{ trigger = ";shrug"; replace = "¯\\_(ツ)_/¯"; }
];
};
```
They're written to `~/.config/espanso/match/default.yml`. Only the plain
trigger→replace form is exposed here; for **dynamic** matches (dates, shell
output, forms), drop a hand-written `.yml` beside it in
`~/.config/espanso/match/`. espanso loads every file in that directory, so the
two live together happily.
`matches` is one value, not an accumulator: naming it in your host discards
whatever the desktop set rather than appending to it. If you want the desktop's
snippets plus your own, restate both, or put yours in a hand-written `.yml`
beside the generated one, where nothing overwrites anything.
## Works with [#works-with]
Nothing else in the house. Expansion happens in whichever app has focus, so the
room has no bar pill, no palette command and no keybinding of its own.
## Permissions and side effects [#permissions-and-side-effects]
espanso needs a one-time macOS **Accessibility** grant: System Settings ▸ Privacy
& Security ▸ Accessibility ▸ enable Espanso. Until you give it, nothing expands.
haus installs the **signed Espanso.app cask** and runs that, rather than a
nix-store binary, on purpose: the grant is keyed to a stable identity, so it
survives reboots and nixpkgs bumps. You grant it once, not on every rebuild,
and espanso's own "something is wrong" window stops appearing at login, since
that window only ever meant the grant went missing.
## Remove it [#remove-it]
```nix
haus.snippets.enable = false;
```
The cask is no longer declared and the generated match file stops being written.
Revoke the Accessibility grant yourself if you want it gone, and remove any
hand-written `.yml` files you added; haus never wrote those, so it doesn't take
them away.
## Options [#options]
Every setting, with types and defaults:
[`haus.snippets`](/docs/haus/reference/options#text-expansion).
# Windows (/docs/haus/rooms/windows)
**prowl** is the tiling room. Windows arrange themselves into a tree instead of
piling up, and the keyboard moves them around it: launch, focus, resize, throw
one to another workspace, without reaching for the mouse. It feels like a tiling
Linux rig; underneath it is still native macOS windows, driven by
[AeroSpace](https://github.com/nikitabobko/AeroSpace).
Two keys carry almost all of it:
* **⇪ Caps-Lock is the leader.** Tap it and the bar turns into letter hints;
press an app's letter to launch or focus it. It is also the door to moving
focus, resizing, and jumping workspaces.
* **⌥ Option is the everyday modifier**, for layouts and workspace moves.
Focus-by-arrow deliberately sits *behind* the leader rather than on a bare `⌥`
chord: `⌥`, `⌘` and `⌃` with the arrow keys are all macOS text navigation, and a
window manager that took them would break typing everywhere. `⌥H/J/K/L` is there
too, for hands that want Vim keys.
## Enable it [#enable-it]
```nix
haus.prowl.enable = true;
haus.keys.leader = "caps"; # or "none" to keep Caps Lock as Caps Lock
haus.keys.windowNav = "alt";
```
## Tap ⇪, then [#tap--then]
| Key | What happens |
| ---------------------- | ------------------------------------------------------------------------------ |
| `` | Launch or focus that app: `T` terminal, `B` browser, and whatever else you add |
| `1`–`4` | Go to workspace 1–4 |
| `⇧` + any of the above | **Throw** the focused window there and follow it |
| `←↓↑→` | Move focus, and stay in navigate mode so arrows keep working |
| `-` / `=` | Shrink / grow, repeatably |
| `V` / `E` | Clipboard history / emoji picker, via pounce |
| `Z` | Reopen the last closed app |
| `,` | macOS System Settings: the `⌘,` convention, one level up |
| `` ` `` | Re-sort every window to its workspace |
| `/` | The cheatsheet |
| `Esc` | Out |
The `⇧` row is the one worth learning: going somewhere and taking the window
with you is the same motion one `⇧` apart. To send a window away and *stay*,
throw it and `⌘Tab` back.
## Without the leader [#without-the-leader]
| Keys | What happens |
| ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `⌥/` | Tiles layout; toggles the split horizontal ↔ vertical |
| `⌥,` | Accordion layout |
| `⌥F` | Fullscreen |
| `⌥⇧Tab` | Move this workspace to the next monitor |
| `⌥⇧;` | **Service mode**: `R` flattens the tree, `F` floats a window, `Backspace` closes every other window, `⌥⇧H/J/K/L` joins a neighbour |
`⌥Tab` is deliberately unbound:
[pounce](/docs/pounce/beyond-launching#a-window-switcher-not-an-app-switcher)'s `⌘Tab` answers the same question
better, with a most-recently-used list of *windows* that goes as far back as you
like and gathers its rows by workspace.
macOS scrambles window positions when the machine wakes. prowl watches for that
and re-sorts them to their workspaces on its own; ⇪ then
\` does it on demand.
## Not fond of these keys? [#not-fond-of-these-keys]
None of it is baked in. Three options move the whole vocabulary at once:
```nix
haus.keys.leader = "caps"; # caps | alt-space | none
haus.keys.windowNav = "alt"; # alt | ctrl-alt | cmd-alt | none
haus.keys.palette = "cmd-space"; # cmd-space | alt-space | ctrl-space | none
```
The three reasons people change them:
* **Keep Caps-Lock as Caps-Lock.** `leader = "alt-space"` gives you the launcher
without remapping the key.
* **A non-US layout.** On many layouts `⌥`+letter is how you type an accented
character, so a window manager owning `⌥` makes the keyboard unusable.
`windowNav = "ctrl-alt"` moves every chord in one go, and the cheatsheet
moves with it, because both are generated from the same table.
* **Mouse-first.** `leader = "none"` with `windowNav = "none"` leaves the
keyboard entirely alone: prowl still tiles, but claims no chords.
`palette = "none"` stops haus taking ⌘Space from Spotlight; it's the only value
that takes it. On a machine that already ran with it, re-enable Spotlight's
shortcut yourself in System Settings ▸ Keyboard ▸ Keyboard Shortcuts; nothing in
haus gives it back.
Whatever you pick, the in-system cheatsheet (tap the leader, then `/`)
describes the keys you actually have, not the defaults above.
## Workspaces, and what lives on them [#workspaces-and-what-lives-on-them]
A workspace is a named place with apps that belong to it. Declare one, and its
apps' windows move there by themselves:
```nix
haus.workspaces.comms = {
key = "c"; # ⇪ ⇧C throws the focused window here
icon = ":slack:"; # the bar pill's glyph
apps = [ "slack" "discord" ]; # roster ids
};
```
The attribute name **is** the workspace id: a letter like `T`, or a word like
`comms`. Three fields:
* **`apps`**: [roster](/docs/haus/rooms/apps) ids that live here.
Launching any of them from the leader lands you here. An app belongs to at
most one workspace, and naming an id that isn't in the roster warns rather
than failing quietly.
* **`key`**: the `⇪ ⇧`-throw key. There is no bare-key binding for a
workspace: that namespace belongs to the launcher letters, one of which
usually doubles as "open something here". `null` means the workspace is
reachable only by launching an app that belongs to it.
* **`icon`**: a [SketchyBar app-font](https://github.com/kvndrsslr/sketchybar-app-font)
ligature like `:slack:` for the bar's pill; any other string is drawn in the
bar's own font.
Workspaces `1`–`4` always exist, whatever any app claims.
The launcher letters themselves come from `haus.roster`, the one list that also
says where each app is installed from; that's [the Apps room](/docs/haus/rooms/apps).
Out of the box the only letters taken are `T` for the terminal and `B` for the
browser.
## Turning it off [#turning-it-off]
```nix
haus.prowl.enable = false;
```
The Caps-Lock remap, the launcher and AeroSpace all stay out of the way. Nothing
else in haus depends on it; [Leaving](/docs/haus/leaving#switch-off-a-room)
does the same for the other rooms.
## Works with [#works-with]
* **[Bar](/docs/haus/rooms/bar)**: the workspace pills, and the letter hints
the leader draws, are this room's contribution to the bar. No bar, no hints;
the tiling itself is unaffected.
* **[Apps](/docs/haus/rooms/apps)**: a roster entry's `key` is what the leader
launches, and its workspace is where its windows land.
* **[Launcher](/docs/haus/rooms/launcher)**: its opt-in `⌘Tab` window switcher
answers the same question from the other side.
## Options [#options]
Every setting, with types and defaults:
[Windows](/docs/haus/reference/options#windows).
# What perch is (/docs/perch)
You know the dance: drag a file, realise the window you wanted is buried under
four others, let go, dig it out, drag again.
**perch** ends it. Start dragging **anything**, flick up to the notch, and a
small shelf drops down to catch it. Pile in as much as you like, from as many
places as you like. Then grab any tile and carry the whole group to its real
home in one motion.
It has no Dock icon and asks for no permissions. It sees what you drop on it,
and nothing else.
These pages are the manual. [The perch page](/perch) is the short version: one
screen, the dance, and the install command.
## What it holds [#what-it-holds]
Files, folders, Photos exports, images dragged out of Safari, links and plain
text all land in the same shelf. Take one tile or grab the stack and take the
lot; whatever the destination accepts leaves the shelf, and a refused drop
springs back.
**It hands out copies.** perch stages its own copy of whatever you drop and
hands out copies of that. It never moves, renames, edits or deletes the thing
you dragged in, so an interrupted drag cannot lose data. A tile only appears
once its copy has finished, so nothing on the shelf is half-written and quitting
doesn't lose it.
## What it doesn't do [#what-it-doesnt-do]
* **No permissions.** No Accessibility, no Input Monitoring, no Screen
Recording, no Dock icon.
* **No telemetry.** Nothing about your files is ever written to a log.
* **Nearly offline.** The only thing perch sends to the internet is an hourly
look at its own release tag, and Settings turns that off. It also listens on
your own network for an iPhone you paired yourself, encrypted end to end and
off in Settings if you don't want it.
## Questions [#questions]
**Where do my files live?** Where they already were. perch keeps its copies
inside its own sandbox container, and it only ever knows about the things you
dragged onto it. The [privacy policy](/perch/privacy) is the whole story, and
it's shorter than this page.
**What licence?** Every line of perch is public and readable on GitHub, and each
release becomes Apache-2.0 two years after it ships. Until then you may read it,
change it and build it for yourself; what you may not do is turn it into a
competing product or hand out your own builds of it. That's [fair
source](https://fsl.software), and it's the honest version of "mostly open".
**Another shelf app?** There are others, and they're good. perch's angle is
dependability and restraint: copies rather than moves, no permissions to grant,
no telemetry, and a shelf that lives where your hand already is.
## Inside a haus machine [#inside-a-haus-machine]
perch runs perfectly well on its own, and that is how most people meet it. It is
also a **room** in [haus](/docs/haus), the macOS layer, where it is installed
and kept current with the rest of the machine, and where it can hand its
light/dark polarity to macOS. Standalone and in-the-house are the same app; only
the wiring differs. See [the Shelf room](/docs/haus/rooms/shelf).
# Install (/docs/perch/install)
```sh
brew install --cask hausfold/tap/perch
```
That's the whole install. No account, no sign-in, no licence key.
Prefer a download? The [latest release](https://hausfold.co/download/perch) is
the same build, signed with our Apple Developer ID and notarized, so it opens
straight away with no Gatekeeper prompt and no quarantine hack.
## Turn one macOS setting off [#turn-one-macos-setting-off]
In **System Settings ▸ Desktop & Dock**, turn *off* **"Drag windows to top of
screen to enter Mission Control"**.
macOS arms that top-edge trigger for the whole of any drag, files included,
and it fires over the same band the shelf lives in. Leave it on and the Dock
steals the drop. It's the only thing perch asks of your Mac.
## What it needs [#what-it-needs]
* **macOS 14** or newer.
* **A notch is nice, not required.** The shelf works on any Mac and on any
display.
* **Nothing else.** perch asks for none of the permissions that make a Mac app
feel invasive: no Accessibility, no Input Monitoring, no Screen Recording.
## On a haus machine [#on-a-haus-machine]
If your Mac runs [haus](/docs/haus), let the Shelf room own perch instead; it
installs it and keeps it current with everything else, and survives a rebuild:
```nix
haus.perch.enable = true;
```
```sh
haus rebuild
```
If you already installed it with Homebrew, letting the room own it is the
upgrade. [The Shelf room](/docs/haus/rooms/shelf) has the one option it adds.
## Updating [#updating]
perch checks its own release tag hourly and tells you when there's a newer one;
applying it is always your keystroke, and Settings turns the check off
altogether. On a haus machine, `haus update` carries it with everything else.
# Using the shelf (/docs/perch/using)
The shelf has no window to open and no icon to click. It appears when you're
already dragging something and disappears when you're not.
## Catching a drag [#catching-a-drag]
Start dragging anything, then flick up toward the notch. The shelf drops down
and catches whatever you let go of over it.
You can do that as many times as you like, from as many places as you like
(Finder, Safari, Photos, Mail, a Save dialog), and it all piles onto the same
shelf. Files, folders, images dragged out of a web page, links and plain text
all land the same way.
## Carrying it out [#carrying-it-out]
Drag a tile off the shelf and drop it where it belongs. Grab the **stack** and
you take everything at once.
Whatever the destination accepts leaves the shelf; a refused drop springs back
rather than vanishing. Because perch hands out copies of its own staged copy,
dropping the same pile into three places is three drags, not three trips back to
where the files came from.
Nothing on the shelf is ever half-written, which is also why a big folder takes
a moment to show up, and why quitting mid-copy doesn't lose it.
## What stays, and for how long [#what-stays-and-for-how-long]
Nothing leaves the shelf on its own. **Clear asks first**, and the expiry timer
is **off** unless you turn it on in Settings, so a pile you left there
yesterday is still a pile today.
Your originals are untouched throughout: perch stages a copy inside its own
sandbox container and never moves, renames, edits or deletes the thing you
dragged in.
## Settings worth knowing [#settings-worth-knowing]
| | |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| **Expiry** | Off by default. Turn it on to have the shelf empty itself after a set time |
| **Release check** | An hourly look at perch's own release tag, and the only thing it sends anywhere. Turn it off and perch never opens a connection |
| **iPhone** | perch listens on your own network for an iPhone you paired yourself, encrypted end to end. Off if you don't want it |
| **Appearance** | Follows your Mac's Light/Dark, or a fixed polarity. On a haus machine, `haus.perch.followSystemAppearance` is the same switch |
# What pounce is (/docs/pounce)
**Pounce** is a free, open-source, native command palette for macOS: a fast,
dmenu-style picker you summon with a hotkey. Hit **⌘Space**, fuzzy-type, hit
**Return**. It launches apps, runs commands, answers quick questions inline,
and hosts a handful of built-in modes: clipboard history, an emoji and symbols
grid, screenshots, and more.
Every launcher lets you open an app. The moment you want it to do something of
your own, you are reading an extension SDK, making an account, or waiting for
someone else to publish the thing you needed twenty minutes ago.
Pounce takes the other road. Its whole personality is one idea: **every command
is a file.** Drop a shell script in a folder and it's in the palette: five
lines, two comments, no plugin API, no extension store, no account.
It is [MIT licensed](https://github.com/hausfold/pounce/blob/main/LICENSE)
(every feature in these pages, no paid tier), and it installs standalone from
Homebrew with **no Nix required**.
## What's in it [#whats-in-it]
| | |
| --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| **Apps & actions** | One list, fuzzy-matched. Type `saf`, get Safari; your own commands sit in the same list, ranked the same way. |
| **Quick answers** | `2*847`, `72 f in c`, `100 usd in eur`, `14:00 utc in pst`: the answer pins to the top row and `⏎` copies it. |
| **Clipboard history** | Your recent copies in a two-pane view, searchable, with an optional paste straight back into the app you came from. |
| **Find Files** | Search by name as you type, over Spotlight's own index. `⏎` opens, `⌘⏎` reveals in Finder, `⌥⏎` copies the path. |
| **Emoji & symbols** | One grid for both: type `command` and get ⌘, `arrow` and get →. Plain Unicode, so it pastes anywhere. |
| **Settings panes** | Every System Settings pane by name: "Displays" goes to Displays, not to the Settings window. |
| **Windows** | An opt-in `⌘Tab` that walks your actual *windows*, newest first, with type-to-filter. |
| **Plugins** | Docker, SSH hosts, Tailscale, Spotify, Bluetooth, audio devices, GitHub, caffeinate. Off by default, on by id, each still just a script. |
A screenshot browser, a live camera peek and a searchable cheatsheet live
beside them.
## How it behaves [#how-it-behaves]
* **Instant.** The daemon holds the hotkey in-process, so a press lands in an
already-warm program: no shell, no client to spawn, no round trip.
* **It learns.** Match quality, then frequency × recency on a 72-hour
half-life, plus a nudge for apps you've just installed. What you meant is
usually the first row.
* **Quiet.** No account, no sign-in, no telemetry, nothing to log in to and
nothing reported back.
* **Offline.** Two outbound calls exist and both are optional: daily reference
rates for currency conversion, and an hourly look at pounce's own release
tag. Either can be turned off in [config](/docs/pounce/config); everything
else works with the Wi-Fi off.
* **Updates are nudged, never automatic.** A pending release pins itself to the
first row and notifies at most once a day; `⌘⏎` skips that version. Applying
it is always your keystroke.
* **Diagnosable.** `pounce doctor` answers the three questions that go wrong:
is the daemon up, is Accessibility granted, is something else eating your
hotkey.
## Questions [#questions]
**Free how?** Free as in no money and free as in MIT. Read it, fork it, ship
your own; there is no licence key, no trial timer and no tier above the one
you have.
**Another launcher?** There are good ones, and if you want a marketplace of
ready-made extensions you should use one of them. Pounce's angle is ownership:
the extension API is the shell you already know, and nothing you write depends
on us staying interested.
**My scripts?** They stay yours, as files, in your own folder. Nothing is
imported into a database, so backing them up is backing up a directory and
moving Macs is copying one.
## Inside a haus machine [#inside-a-haus-machine]
Pounce runs perfectly well on its own, and that is how most people meet it. It
is also a **room** in [haus](/docs/haus), the macOS layer, where the daemon,
the hotkey and the Accessibility grant are wired up with the rest of the
machine and survive a rebuild. Standalone and in-the-house are the same app;
only the wiring differs. See [the Launcher room](/docs/haus/rooms/launcher).
# Beyond launching (/docs/pounce/beyond-launching)
Both of these are **off by default**, and both need the [Accessibility
grant](/docs/pounce/install#grant-accessibility). They share one grant and one
set of observers, so turning both on costs one approval, not two.
## A window switcher, not an app switcher [#a-window-switcher-not-an-app-switcher]
Pounce ships an **MRU window switcher**: tap the chord to bounce to your last
window even on another workspace, hold it and keep tapping to walk older ones,
or type while holding to fuzzy-filter. It moves by *window*, so two Ghostty
windows are two rows.
```jsonc
{
"windows": {
"enabled": true,
"key": "tab",
"modifiers": ["cmd"]
}
}
```
Inside haus that is `haus.pounce.windowSwitcher`, where it defaults **on**.
**With a tiling window manager**, rows gather by workspace under a header each,
and focusing goes through `aerospace focus --window-id` so a window parked
elsewhere surfaces properly. One deliberate wrinkle: a bare tap-and-release
skips the workspace you're on and lands on the most recent window on a
**different** one: with two windows tiled side by side, the most recent is one
you're already looking at, so landing there wouldn't be a switch. The skipped
siblings are still the rows right below you.
The `windows` block is captured when the daemon arms, so an edit needs a
restart, not just a re-open:
```sh
launchctl kickstart -k "gui/$(id -u)/com.hausfold.pounce"
```
## Quit an app when its last window closes [#quit-an-app-when-its-last-window-closes]
macOS keeps an app running after you close its last window. If your muscle
memory is from Windows, that's a ⌘Q you forgot every time.
```jsonc
{
"autoQuit": {
"enabled": true,
"delay": 2,
"exclude": ["com.apple.finder"]
}
}
```
In haus: `haus.pounce.autoQuit.enable`, off by default.
**Asked, not killed**: the same Quit event ⌘Q sends, so unsaved work puts up
its sheet and stays open.
**The delay is load-bearing.** `delay` (2s, clamped 0.25–3600) is what tells "I'm
done with this app" apart from "close this window, open another," which is what
a browser does on ⌘N.
**`exclude` replaces the default, it doesn't extend it.** Out of the box it is
`["com.apple.finder"]`, since Finder is the one app macOS runs windowless by
design. Name anything else and you have dropped Finder from the list.
An app doing work in the background is still just an app with a window. Closing
the Docker Desktop dashboard asks Docker to quit, which stops your containers.
The same shape catches media players, torrent clients, and any chat app you
keep open for notifications. That's what `exclude` is for.
The whole `autoQuit` block is startup-only too, so **any** edit inside it needs
the daemon restart above, not just flipping `enabled`.
# CLI reference (/docs/pounce/cli)
```sh
pounce --launcher # apps + commands palette (the default mode)
pounce --max-empty 7 # rows to show before you type
pounce -p "Pick:" # generic picker; reads lines from stdin
pounce -i "sf.symbol.name" # icon for the picker
pounce -p "Search:" --chain # picker whose free-text Enter feeds another pounce step
pounce run cmd:emoji # run one item by its key (for external binders)
# built-in windows (items, not flags)
pounce run mode:clipboard # clipboard history
pounce run mode:emoji # emoji + symbols picker
pounce run mode:screenshots # screenshot browser
pounce run mode:camera # live camera preview
pounce run mode:filesearch # file/folder search (Spotlight index)
pounce run app:/Applications/Ghostty.app
pounce --cheatsheet [path] # cheatsheet overlay
pounce --transform 'tr a-z A-Z' # rewrite the selected text through a shell filter
# settings
pounce config print # print the annotated config, touching nothing
pounce config init # write ~/.config/pounce/config.json
# housekeeping
pounce doctor # diagnose a dead/slow hotkey or binding
pounce --request-accessibility / --check-accessibility
pounce --request-bluetooth / --check-bluetooth
pounce --help / --version
```
## Flags [#flags]
| Flag | Purpose |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `-p`, `--placeholder` | Prompt text for the search field |
| `-i`, `--icon` | SF Symbol icon for the picker |
| `--chain [keys]` | Mark a free-text commit as feeding another `pounce` step. Optional comma-separated action list (`--chain enter,opt`); bare `--chain` means `enter` |
| `--actions ` | Label the action bar on a rowless step: `"Go\|shift:New line\|cmd:Screenshot\|opt:Drafts"` |
| `--draft ` | Keep typed text on any non-commit dismissal, filed under `` |
| `--query ` | Open with the box pre-filled, caret at the end |
| `--transform ` | Pipe the current selection through a shell filter and paste the result back (needs Accessibility) |
| `--copy-file ` | Copy a file's contents to the clipboard |
| `--max-empty ` | How many rows to show before anything is typed |
| `--cheatsheet [path]` | Open the cheatsheet overlay |
## Subcommands [#subcommands]
| Subcommand | Purpose |
| ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
| `run ` | Run one item by the key `items` uses, for binders that own the keystroke |
| `config print` / `config init` | Print the annotated config, or write one |
| `doctor` | Report the daemon, the grant, and every binding actually armed |
| `focus ` | Focus/DND: `focus status\|toggle\|on\|off`, forwarded to the daemon |
| `drafts ` | `save` (stdin) / `list` / `get ` / `rm ` / `clear`. Files live in `~/.local/state/pounce/drafts/.tsv`, newest first, capped at 20 |
## Homebrew binaries [#homebrew-binaries]
`brew install pounce` puts `pounce` (the app/daemon), `pounce-palette` (the
launcher wrapper, for binding a hotkey externally), and one `pounce-`
wrapper per built-in on your `PATH`.
Inside haus, set the palette up via `haus.pounce` instead; the module handles
the daemon, the hotkey, and permission survival for you.
# Commands (/docs/pounce/commands)
A command is a shell script, and everything in this page is one, including
every built-in. There is no privileged tier: read any of them, copy one, or
replace it with your own.
## Built-in commands [#built-in-commands]
Out of the box: **Activity Monitor**, **Brew Services**, **Camera**,
**Capitalize** / **Lowercase** (rewrite the text selected in *any* app),
**Clipboard History**, **Emoji & Symbols**, **Find Files**, **Force Quit**,
**Lock Screen**, **Ports**, **Report Pounce Issue**, **Screenshots**, and
**System Settings**, where every pane is its own command, so "Displays" goes
to Displays rather than to the Settings window.
On a Homebrew install there is one more: **Update Pounce**, which self-updates
in place. `brew upgrade pounce` on Homebrew, an in-place swap when the app was
dragged to `/Applications`, or a nudge to `haus update` on Nix and haus, since
the next rebuild would revert an in-place update anyway.
## Two-step commands [#two-step-commands]
Some commands open a second picker instead of acting immediately (**Brew
Services** lists services, **Force Quit** lists processes), and Pounce swaps the
list **in place**, with no window flicker. That is not a special capability;
it's a script calling `pounce` again. See [writing your own
command](/docs/pounce/writing-commands#submenus-and-chaining).
## The plugin shelf [#the-plugin-shelf]
A shelf of plugins ships **off by default**, because each assumes a specific
tool, service, or app:
| Plugin | What it does | Needs |
| ----------------- | ----------------------------------------------- | ----------------------------------- |
| **Audio Devices** | Switch sound output & input | `switchaudio-osx` |
| **Bluetooth** | Connect & disconnect paired devices | `blueutil` |
| **Caffeinate** | Keep the Mac awake, indefinitely or on a timer | nothing |
| **Docker** | Start/stop/restart & tail container logs | any engine that answers `docker ps` |
| **GitHub** | Jump to your PRs, reviews, issues & repos | `gh`, authenticated |
| **Perplexity** | A fresh Perplexity thread from a typed question | a browser |
| **Spotify** | Playback controls | Spotify.app |
| **SSH Hosts** | Pick a host from `~/.ssh/config` | hosts in `~/.ssh/config` |
| **Tailscale** | Connect/disconnect, copy an IP | the `tailscale` CLI |
On Nix, enable by id:
```nix
pounce-commands.override { plugins = [ "docker" "ssh" ]; }
```
Off Nix, every plugin is still just one script: copy it from
[`pkgs/pounce-commands/optional/`](https://github.com/hausfold/pounce/tree/main/pkgs/pounce-commands/optional)
into `~/.config/pounce/commands/` and it is live on the next ⌘Space.
## Where commands are discovered [#where-commands-are-discovered]
Later sources **shadow** earlier ones by filename, so yours always win:
1. `$POUNCE_BUILTIN_DIR`: the built-ins that ship with the package
2. `$POUNCE_EXTRA_COMMAND_DIRS` (colon-separated): layers added by Nix consumers like haus
3. `$POUNCE_COMMAND_PATH` (colon-separated): ad-hoc extra directories
4. `~/.config/pounce/commands`: **yours**, highest precedence
Want to change what "Lock Screen" does? Drop your own `lock.sh` in
`~/.config/pounce/commands/` with the same filename, and yours takes over.
A non-`.sh` file (a `.tsv` or `.json` data catalog) is ignored wherever it
sits. A helper script that shouldn't itself be a command belongs in a
subdirectory (e.g. `data/`), which Pounce doesn't descend into.
## Hiding, renaming and binding a row [#hiding-renaming-and-binding-a-row]
Any command, app or built-in mode can be dropped from the list, given a search
shorthand, or given a global key of its own, all in one map, in
[config](/docs/pounce/config#per-item-settings-items):
```jsonc
{
"items": {
"cmd:brew-services": { "enabled": false },
"cmd:emoji": { "alias": "emo", "hotkey": "opt+space e" }
}
}
```
# Config reference (/docs/pounce/config)
## Config file [#config-file]
Pounce reads `~/.config/pounce/config.json`, re-read on every open. No restart
is needed, except for the three things the daemon sets up **at startup**:
`windows`, `autoQuit`, and the `items` hotkeys. Those are captured when armed
and need a restart to change:
```sh
launchctl kickstart -k "gui/$(id -u)/com.hausfold.pounce"
```
Inside haus, a rebuild does that bounce for you when one of those keys moves.
```sh
pounce config init # writes ~/.config/pounce/config.json
pounce config print # …or just look at it, touching nothing
```
`init` writes **every** setting at its default, one commented-out line each
with a sentence above it. Nothing changes until you uncomment a line, and you
make the file minimal by deleting the lines you never touched. It never
overwrites an existing config (it writes `config.json.new` beside it instead;
`--force` replaces).
Your `config.json` is generated from `haus.pounce.*`, and the next rebuild
would put the generated one straight back. Change it in your host file
instead; see [the Launcher room](/docs/haus/rooms/launcher).
Comments and trailing commas are fine (JSON5); unknown keys are ignored, so an
older Pounce never chokes on a config written by a newer one.
```jsonc
{
"theme": "nebelung", // "nebelung" (default), "mocha", or a themes/ file
"themeLight": "nebelung-latte", // used in macOS Light Mode
"themeDark": "nebelung", // used in macOS Dark Mode
"windowMode": "default", // "default" (720px) or "compact" (600px, tighter)
"scale": 1.0, // 0.8-2.0: how big the whole UI is drawn
"hotkey": {
"enabled": true,
"key": "space", // "space", "return", "tab", "escape", "a"–"z", "0"–"9"
"modifiers": ["cmd"] // any of "cmd", "shift", "opt", "ctrl"
},
"clipboard": {
"enabled": true,
"maxEntries": 200,
"blacklistBundleIds": ["com.apple.Passwords"],
"autoPaste": false // synthesize ⌘V into the prior app (needs Accessibility)
},
"quickAnswers": { "currency": true }, // ECB rates, so "100 usd in eur" answers inline
"updates": { "check": true }, // nudge (never install) when a release is out
"fileSearch": {
"enabled": true,
"homeOnly": true, // scope to ~ instead of the whole Spotlight index
"maxResults": 60
},
"apps": {
"demoteBundleIds": [], // sink these below everything else; REPLACES a built-in list of Apple utilities (Feedback Assistant, Audio MIDI Setup, …)
"hideBundleIds": [] // drop these from the list entirely
},
"windows": {
"enabled": false, // the MRU window switcher (needs Accessibility)
"key": "tab",
"modifiers": ["cmd"]
},
"autoQuit": {
"enabled": false,
"delay": 2,
"exclude": ["com.apple.finder"] // REPLACES the default, doesn't extend it
},
"items": { // per-item enable / alias / hotkey, see below
"cmd:emoji": { "alias": "emo", "hotkey": "opt+space e" }
}
}
```
`quickAnswers.currency` and `updates.check` are the only two settings that
touch the network; set both `false` for a fully offline Pounce.
`autoQuit.enabled` and `windows.enabled` turn on [the two opt-in
behaviours](/docs/pounce/beyond-launching); both blocks are startup-only, so
**any** edit inside them needs the daemon restart above, not just flipping
`enabled`.
`windowMode` and `scale` are independent knobs (proportions vs. size) and
they compose: a compact launcher at `1.4` is still the compact layout, just
bigger. Values outside 0.8–2.0 clamp rather than reject. On haus, `scale` is
written for you from `haus.ui.scale`.
## Themes [#themes]
`themeLight` / `themeDark` are resolved per open, so flipping macOS appearance
shows on the next summon; either falls back to `theme`, and `theme` alone pins
one palette for both modes.
Any `theme` value that isn't built-in resolves to
`~/.config/pounce/themes/.json`: a flat catppuccin-style `name → "#hex"`
map ([nebelung's](https://github.com/hausfold/nebelung) `palette/*.hex.json`
files verbatim), which is how a desktop's `theme.flavor` / `theme.contrast`
reach Pounce without a rebuild:
```sh
mkdir -p ~/.config/pounce/themes
curl -fsSLo ~/.config/pounce/themes/nebelung-latte.json \
https://raw.githubusercontent.com/hausfold/nebelung/main/palette/nebelung-latte.hex.json
# config.json: "theme": "nebelung-latte"
```
## Per-item settings (`items`) [#per-item-settings-items]
One map for what you'd otherwise want three keys: hide a row, give it a search
shorthand, give it a global key. Each entry is keyed by an **item key**:
| Item key | Addresses |
| --------------------------- | ------------------------------------------------------------------------------------------ |
| `cmd:` | a command script, by filename without `.sh` |
| `app:/Applications/Foo.app` | an application, by path |
| `mode:` | a built-in window: `launcher`, `clipboard`, `emoji`, `screenshots`, `camera`, `filesearch` |
```jsonc
{
"items": {
"cmd:emoji": { "alias": "emo", "hotkey": "opt+e" },
"cmd:brew-services": { "enabled": false },
"app:/Applications/Ghostty.app": { "alias": "term", "hotkey": "opt+t" },
"mode:clipboard": { "hotkey": "cmd+shift+v" }
}
}
```
`enabled: false` drops the row without disarming a hotkey bound to it; keeping
an item off the list but on a key is a legitimate setup. `alias` is a search
shorthand that wins over whatever app fuzzy-matches the same letters. `hotkey`
runs the item directly, skipping the palette: the last segment is the key, the
rest modifiers, and the object form `{"key": …, "modifiers": …}` works too.
The laptop **Fn/Globe key** is a one-step special case
(`"mode:emoji": { "hotkey": "fn" }`), using a keyboard event tap that needs
Accessibility, firing only on a lone tap so Fn combinations keep working.
`"globe"` / `"function"` are accepted aliases; haus binds emoji to it by
default, and `haus.pounce.items."mode:emoji".hotkey = null` leaves Globe
native.
### Leader sequences [#leader-sequences]
Add a space for a two-step key: `"opt+space e"` is ⌥Space then E, the
Emacs/VS Code notation. Sequences sharing a leader share it (⌥Space registers
once), and can run longer than two steps.
The payoff on a tiling setup: a leader can't collide with the ⌥/⌘ chords a
window manager already owns, and it needs **no Accessibility grant**: pressing
it grabs the next-step keys as ordinary global hotkeys for \~2s and releases
them the instant one fires. Escape cancels; hesitate \~0.45s and a which-key
overlay lists the next keys. `pounce doctor` reports every binding it actually
armed.
### Driving Pounce from another binder [#driving-pounce-from-another-binder]
AeroSpace modes, skhd, Shortcuts:
```sh
pounce run cmd:emoji
pounce run mode:clipboard
```
Same target grammar as `items`, through the same path a native binding takes; a
malformed target exits non-zero with a reason.
## File paths [#file-paths]
| Path | What |
| ------------------------------------- | ---------------------------------- |
| `~/.config/pounce/config.json` | Configuration |
| `~/.config/pounce/commands/` | Your commands (highest precedence) |
| `~/.config/pounce/themes/` | Extra palettes, by name |
| `~/.config/pounce/cheatsheet.json` | Optional cheatsheet content |
| `~/.local/share/pounce/frecency.json` | Usage history for ranking |
| `~/.local/share/pounce/pounce.sock` | Daemon control socket |
| `~/.local/state/pounce/drafts/` | Saved drafts, per `--draft` key |
## Environment variables [#environment-variables]
Set by packagers (haus), rarely by hand: `POUNCE_BUILTIN_DIR`,
`POUNCE_EXTRA_COMMAND_DIRS` (colon-separated Nix layers),
`POUNCE_COMMAND_PATH` (colon-separated ad-hoc dirs).
# Install (/docs/pounce/install)
Pounce is a prebuilt, Developer-ID-signed and notarized `Pounce.app`: no
compile step, no Xcode command-line tools to fetch, no Gatekeeper prompt.
## Homebrew [#homebrew]
```sh
brew tap hausfold/tap
brew install pounce
brew services start pounce # start the daemon
pounce --request-accessibility # one-time, approve the dialog
```
Because that signing identity is stable, the Accessibility grant **survives
`brew upgrade`**: approve it once, not once per release.
## Or a download [#or-a-download]
The [latest release](https://hausfold.co/download/pounce) is the same build as
a DMG: drag it to Applications and open it. First launch registers a login item
and starts the daemon; approve the login item in System Settings if macOS
asks.
With Nix, without installing anything:
`nix run github:hausfold/pounce -- --help`.
## What it needs [#what-it-needs]
| | |
| ----------------- | ------------------------------------------------ |
| **macOS 14** | Sonoma or newer, on Apple Silicon |
| **Accessibility** | One permission (see below) |
| **Nothing else** | No account, no key, no sign-in, no Xcode, no Nix |
## One key to free up [#one-key-to-free-up]
Pounce wants **⌘Space**, and on a stock Mac that key belongs to Spotlight.
Spotlight wins **silently**: registration *succeeds*, so Pounce can't tell,
and macOS just routes the key elsewhere. The daemon checks for this at startup
and names the shortcut that beat it in its log.
Free the key in **System Settings → Keyboard → Keyboard Shortcuts →
Spotlight**, then restart the daemon.
Would you rather keep Spotlight where it is? The hotkey is a line of
[config](/docs/pounce/config#config-file); the `hotkey` block takes a `key`
and a list of `modifiers`:
```jsonc
{ "hotkey": { "key": "space", "modifiers": ["opt"] } } // ⌥Space instead
```
(On a haus machine you write `haus.keys.palette = "alt-space"` instead, and the
rebuild generates that block for you.) Inside
[haus](/docs/haus/rooms/launcher) ⌘Space is handed over for you either way,
because haus turns Spotlight's binding off.
## Grant Accessibility [#grant-accessibility]
Clipboard auto-paste, emoji paste, and modifier-only bindings like Fn/Globe all
synthesize keystrokes; the [window switcher and
auto-quit](/docs/pounce/beyond-launching) read a live window snapshot. macOS
gates all of it behind one grant:
```sh
pounce --request-accessibility # approve the dialog
pounce --check-accessibility # prints true when granted
```
Pounce is honest about why it asks: a global hotkey that works over every app
can't be had without it. Two features ask for their own the first time you use
them: the camera peek, and the Bluetooth plugin. Nothing else: no Input
Monitoring, no Screen Recording, no Full Disk Access.
A plain Nix rebuild is ad-hoc signed, which would drop the grant every time.
Inside haus, set `haus.pounce.signingIdentity` and the desktop re-signs a
stable copy of the app with your identity, so the grant persists across
rebuilds. See [the Launcher room](/docs/haus/rooms/launcher).
## Check it landed [#check-it-landed]
```sh
pounce doctor
```
It reports whether the daemon is up, whether Accessibility is granted, every
binding it actually armed, and what beat it to a hotkey if something did.
# Using the palette (/docs/pounce/using)
## Summon it [#summon-it]
* **⌘Space**: the default hotkey, registered in-process by the daemon so it
opens instantly.
* **Fn/Globe**: a lone tap opens the emoji and symbols grid. Fn combinations
keep working; this reuses Pounce's Accessibility grant and only replaces
Globe's stock action while armed. (On by default inside haus;
`"mode:emoji": { "hotkey": "fn" }` elsewhere.)
* Change either in [config](/docs/pounce/config#config-file): the `hotkey`
block takes a `key` and `modifiers`.
Not opening at all? [Install](/docs/pounce/install#one-key-to-free-up) covers
the ⌘Space collision with Spotlight, which fails silently.
## Navigating [#navigating]
| Key | Action |
| --------------------- | ------------------------------------------------------- |
| Type | Fuzzy-search in real time |
| `↑` / `↓` | Move the selection |
| `Return` | Run the default action |
| `⇧Return` | Insert a newline; the query field is multi-line |
| `⌘Return` | The modifier action (often "Reveal in Finder" for apps) |
| `⌥Return` / `⌃Return` | Alternate actions, when shown |
| `Esc` | Dismiss |
The action bar at the bottom always shows which modifiers do what for the
selected row.
## How ranking works [#how-ranking-works]
Three signals decide the order, so the thing you want is usually already on
top:
* **Fuzzy match quality**: consecutive runs, word-boundary hits, and titles
counting double subtitles.
* **Frecency**: frequency × recency, on a 72-hour half-life.
* **A freshly-installed boost** for anything installed in the last week,
halving every two days.
An app you'd rather never see can be hidden or sunk with `apps.hideBundleIds` /
`apps.demoteBundleIds`, and any row can take a search shorthand; see
[per-item settings](/docs/pounce/config#per-item-settings-items).
## Quick answers [#quick-answers]
Some queries are answered **in the palette** instead of being fuzzy-matched
against apps and commands. The answer pins to the top as its own row, and
`Return` copies it (no trigger prefix needed):
| Type | Get |
| -------------------------------------------- | -------------------- |
| `2*847`, `2^10`, `sqrt(9)` | Arithmetic |
| `100 + 10%`, `20% of 150` | Percentages |
| `72 f in c`, `1 pound in kg`, `1 gib in mib` | Unit conversion |
| `100 usd in eur`, `$100 in eur` | Currency conversion |
| `14:00 utc in pst`, `2pm utc in tokyo` | Time-zone conversion |
A query no engine can parse just falls through to normal results, and anything
without a digit skips the engines entirely: `safari` stays a search.
Currency uses the European Central Bank's daily reference rates, cached locally
so it keeps answering offline. It is one of only two things that touch the
network (the other is the hourly release check), and
`quickAnswers.currency` in [config](/docs/pounce/config#config-file) turns it
off.
## The built-in modes [#the-built-in-modes]
The palette is not the only window Pounce draws. Each of these is an item you
can run from the palette, bind to a key of its own, or launch from a script:
| Mode | What it is |
| ------------------ | -------------------------------------------------------------------------- |
| `mode:clipboard` | Clipboard history, two-pane, searchable, with optional auto-paste |
| `mode:emoji` | Emoji *and* plain-text symbols in one grid: `command` → ⌘, `not equal` → ≠ |
| `mode:filesearch` | Files and folders by name, over Spotlight's index |
| `mode:screenshots` | A browser for the shots you've taken |
| `mode:camera` | A live camera peek |
`pounce run mode:clipboard` opens one directly; `"mode:clipboard": { "hotkey":
"cmd+shift+v" }` in [config](/docs/pounce/config#per-item-settings-items) gives
it a key. The cheatsheet overlay (`pounce --cheatsheet`) is the sixth window,
and takes its content from `~/.config/pounce/cheatsheet.json`.
# Writing your own command (/docs/pounce/writing-commands)
Pounce has no plugin SDK. A command is **one shell script** with an optional
metadata header. Drop it in a folder and it is in the palette on the next
open, with no build step, no restart, no manifest:
```bash
#!/bin/bash
# pounce: name = Say Hello
# pounce: description = A friendly notification
# pounce: icon = hand.wave
osascript -e 'display notification "🐾" with title "Pounce"'
```
Put it in `~/.config/pounce/commands/`, summon the palette, type `hello`.
The registry re-scans every time you summon. Scripts don't need the executable
bit (Pounce runs them with `bash` either way), but they **must** end in `.sh`,
or Pounce never lists them.
| Header key | Meaning | Default |
| ------------- | ------------------------------------------------------------ | ---------------------------- |
| `name` | Title shown in the palette | the filename (without `.sh`) |
| `description` | Subtitle | *(empty)* |
| `icon` | An [SF Symbol](https://developer.apple.com/sf-symbols/) name | `sparkles` |
| `submenu` | `true` means the command re-invokes Pounce for a second step | `false` |
Parsing stops at the first non-header line, within the first 30 lines.
## Submenus and chaining [#submenus-and-chaining]
Set `submenu = true` and pipe your options through `pounce` again; the list
swaps in place with no flicker:
```bash
#!/bin/bash
# pounce: name = Brew Services
# pounce: submenu = true
service=$(list_services | pounce -p "Service:")
[ -n "$service" ] && toggle_service "$service"
```
When the second step is a **search** rather than a list, pass `--chain`: on an
empty match Enter hands the raw text back, and `--chain` tells Pounce that text
feeds another `pounce`, holding the window's loading skeleton instead of fading
between steps:
```bash
query=$(printf '' | pounce --chain -p "App Store — type a search, then Enter")
[ -n "$query" ] && mas search "$query" | pounce -p "Install:"
```
## A step that takes a paragraph [#a-step-that-takes-a-paragraph]
A step whose answer is a sentence wants more than a filter box. Three flags
turn a `pounce` step into one, and haus's **Spawn Agent** command is the worked
example:
* `--actions` labels more than one verb on a rowless prompt. Return hands back
`\ttext`, where action is `enter` / `cmd` / `opt` / `ctrl`.
* `⇧↵` inserts a newline, and the box grows with the text.
* `--draft ` files the query on every non-commit dismissal;
`pounce drafts get ` reads it back, and `--query ` reopens the
box pre-filled for editing.
```sh
sel=$(printf '' | pounce --chain enter,opt --draft my-prompt \
--actions "Go|shift:New line|cmd:With a screenshot|opt:Drafts" \
-p "What should it do?")
case "$(printf '%s' "$sel" | cut -f1)" in
enter) go "$(printf '%s' "$sel" | cut -f2-)" ;;
cmd) shot "$(printf '%s' "$sel" | cut -f2-)" ;;
opt) show_drafts ;;
esac
```
## Pounce as a generic picker [#pounce-as-a-generic-picker]
Beyond commands, `pounce` is a dmenu-style picker: pipe it lines, get the
chosen one on stdout.
```sh
printf 'a\nb\nc\n' | pounce -p "pick one:"
```
A piped list keeps **your order** (only the launcher's own apps get sorted),
so a ranking your script already computed survives into the picker. Each line
can carry extra tab-separated columns:
```
title subtitle icon actions group
```
`actions` is `label | key:label | key:label…` (the first is Return, the rest
are modifier combos); `group` is an optional section header, as Force Quit does
with *Applications* / *Background*.
## Reading real ones [#reading-real-ones]
The [built-in
commands](https://github.com/hausfold/pounce/tree/main/pkgs/pounce-commands/commands)
are copy-pasteable examples of submenus, grouping and icons, and they are the
same kind of file yours is, in the same format, with no privileged API between
them and you.
# What trill is (/docs/trill)
There is nothing to install, no release and no download. This page exists so
the idea has an address while it is being built. Everything below is intent,
not a shipped feature, and any of it may change or be dropped.
**trill** is a notification compositor for macOS: one place for the things that
interrupt you, with a filter in front of it that you write rather than negotiate
with an app at a time.
The problem it is aimed at is not that Macs notify too much. It's that
notifications arrive from a dozen apps with a dozen different ideas of what is
urgent, and macOS gives you one lever per app: on, or off. What's missing is a
place to say *this one matters, that one can wait until I look*.
## Where it is [#where-it-is]
* **The repo is [`hausfold/trill`](https://github.com/hausfold/trill)**, created
2026-08-09. It is public, and the commit log is the honest status.
* **It was called `flick`** while it incubated in the workshop, so both names
appear in older writing.
* ⚠️ **`nebelhaus/trill` is not it.** That name now resolves to
`nebelhaus/messages`, an archived iMessage client (a different project
entirely). Typing the old org for trill lands you on a tombstone, silently.
## What it isn't yet [#what-it-isnt-yet]
No install command, no `haus.trill.*` options, no room. When there is something
a stranger can run, this tree grows the pages to say so. The [Focus
room](/docs/haus/rooms/focus), which is haus's answer to "go quiet" today, is
where that story starts.