AI agent desktop integration for NixOS. Chat with a local AI agent from a layer-shell panel summoned by a global keybind, with full sandboxing and an extensible skill system.
The stack: niri (Wayland compositor, optional) + Quickshell (panel surface) + pi (coding agent) + llama-swap (local LLM server) + voice-to-text.
The chat panel uses wlr-layer-shell
so the surface is anchored to the screen edge and does not appear in
alt-tab. That rules out GNOME (Mutter has no wlr-layer-shell).
Tested compositors: niri, sway, Hyprland, river, KDE Plasma 6
(Wayland).
The panel coexists with any Wayland desktop shell — including noctalia
if you happen to run one — because it ships as its own quickshell -c pi-chat instance with its own IPC namespace.
| Integration | What you get | You provide |
|---|---|---|
| Full desktop | Niri compositor + pi-chat panel + AI agent + local LLM | A NixOS machine |
| Panel only | pi-chat panel + AI agent + local LLM | Your own Wayland compositor (sway/Hyprland/KDE/…) |
Configure the numtide binary cache to avoid building dependencies from source.
Import nixosModules.distro for the complete experience: niri compositor, noctalia bar, pi-chat Quickshell panel, AI agent, local LLM server. The module enables the AI agent and greetd auto-login into niri by default.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
distro.url = "github:generational-infrastructure/distro";
};
outputs = { nixpkgs, distro, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
distro.nixosModules.distro
{
# Override the default greetd auto-login user.
services.greetd.settings.default_session.user = "alice";
}
];
};
};
}This gives you:
- Mod+T — terminal (alacritty)
- Mod+D — app launcher (fuzzel)
- Mod+A — toggle the pi-chat panel
- Mod+S — toggle voice-to-text recording
- Mod+L / Ctrl+Alt+L — lock the screen (swaylock)
- Mod+Shift+N — restart the pi-chat panel (live-reload after rebuild)
See docs/keybindings.md for the full list of keyboard shortcuts (distro additions plus the inherited niri defaults).
The full desktop module includes voice-to-text out of the box. Press Mod+S to start recording and Mod+S again to stop. Speech is transcribed locally and typed into the focused window.
Already using sway, Hyprland, KDE Plasma 6, or another
wlr-layer-shell-capable Wayland compositor? Import
nixosModules.pi-chat to get just the panel + AI agent + local LLM.
You keep your compositor.
# flake.nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable";
distro.url = "github:generational-infrastructure/distro";
};
outputs = { nixpkgs, distro, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
distro.nixosModules.pi-chat
./configuration.nix
];
};
};
}The panel runs as a user systemd service (pi-chat.service); it
starts at login alongside graphical-session.target and stays
running, hidden by default. Summon it with the bundled
pi-chat-toggle CLI:
pi-chat-toggle # toggle visibility
pi-chat-toggle show # force show
pi-chat-toggle hide # force hide
Wire pi-chat-toggle to whatever compositor keybind you like. Under
the hood it calls
quickshell ipc -c pi-chat call pi-chat toggle, so you can also use
that directly if you prefer.
Examples:
sway (~/.config/sway/config)
bindsym $mod+a exec pi-chat-toggle
Hyprland (~/.config/hypr/hyprland.conf)
bind = SUPER, A, exec, pi-chat-toggle
KDE Plasma 6: System Settings → Shortcuts → Custom Shortcuts → add
a command shortcut bound to pi-chat-toggle.
If you also want voice-to-text, bind voxtype record toggle similarly.
The chat agent (pi-chat) defaults to the local LLM served by
llama-swap. You can add OpenRouter as an
additional backend — pi's built-in openrouter provider exposes
~200 curated models, switchable mid-session from the chat panel.
-
Create a key file on the target host (root-owned, mode
0400):install -m 0400 -o root -g root /dev/stdin /etc/secrets/openrouter-api-key <<< "sk-or-v1-..."
-
Enable the provider in your NixOS config:
services.pi-chat.openrouter = { enable = true; apiKeyFile = "/etc/secrets/openrouter-api-key"; };
The key is loaded as a systemd credential and resolved by pi at request time via
!cat $CREDENTIALS_DIRECTORY/openrouter-api-key— it never lands in the nix store. -
(Optional) Curate or override built-in model metadata via
piModels:services.pi-chat.piModels.providers.openrouter.modelOverrides = { "anthropic/claude-sonnet-4.5".contextWindow = 200000; };
-
(Optional) Make an OpenRouter model the default at session start:
services.pi-chat.defaultModel = "anthropic/claude-sonnet-4.5";
llama-swap stays enabled alongside; pick the provider per session from the chat panel's model selector.
A local memory store extracts durable facts from each chat turn and surfaces relevant ones at the start of any later prompt, across all your chats. On by default for each new chat; the icon in the panel header toggles capture and recall off for that chat, and the eraser next to it wipes the entire store after an inline confirmation.
Anything you type can be picked up by the extractor — flip the toggle off before pasting secrets.
Inspect or prune from the terminal:
sediment stats
sediment list --scope all
sediment recall "favourite colour"
sediment forget <id>checks.x86_64-linux.test-machine is dual-mode. With
OPENROUTER_API_KEY unset it exercises the local llama-swap backend.
With the env var set it switches the in-VM pi-chat to the openrouter
provider and runs a real round-trip against api.openrouter.ai.
Repo-local secrets live in .env (gitignored). direnv loads it on
directory entry via .envrc:
cp .env.example .env
$EDITOR .env # fill in OPENROUTER_API_KEY
direnv allowThen:
# Local-backend mode (default; works under `nix flake check` too):
nix build .#checks.x86_64-linux.test-machine
# OpenRouter mode (requires --impure so eval sees the env var; the
# derivation is marked __impure so the VM gets real internet):
nix build --impure .#checks.x86_64-linux.test-machine
# Interactive VM for poking around:
nix run --impure .#checks.x86_64-linux.test-machine.driverInteractiveIn OpenRouter mode the key value is baked into the local /nix/store
— do not push the resulting store paths to a shared cache.
See LICENSE.