EmitTemplate grammar
The substitution grammar for declarative modules. Parsed once from a module’s emit { ... } block into a small AST, then interpreted per-node against a bindings tree built from the validated input. Full types in crates/knixl-modules/src/template.rs.
Five statement forms
Section titled “Five statement forms”Matching exactly the boundary in docs/03 (substitute, repeat-into-list, fold-into-list-of-attrsets, gate-on-flag, gate-at-runtime):
set <path> <value>: assign a value into an option path.when-flag "<flag>" { ... }: generation-time gate on a bool input. Includes or drops its body.when-config "<cond>" { ... }: runtime gate. Always emits its body, wrapping each assignment inlib.mkIf (<cond>) <value>. The condition is raw Nix offconfig.*with{lookup}interpolation (dry-checked at load like asetpath); the Nix expression itself is opaque and unvalidated. Nestedwhen-configconjoin:(A) && (B).for-each "<var>" in "<repeated-child>" { ... }: iterate a repeated child, binding<var>per item, in KDL source order.list "<path>" from "<repeated-child>" { set "<attr>" <value> ... }: fold a repeated child into a list of attribute sets ([ { ... } { ... } ]) at<path>, one element per child in KDL source order. The child name is the loop binding (from "network"binds{network.field}). Each element is built from innersetstatements (relative attr paths, so nested and quoted keys likeconfig."ipv4.address"work), optionally gated bywhen-flag(generation-time) orwhen-config(which wraps that attr’s value inlib.mkIf). Two inner sets writing the same path is an error.
Values
Section titled “Values”- scalars:
#true,16,"literal" - interpolated string:
"{upstream}", parts are literal or{lookup} - indent string:
(indent-str)#""" ... """#, interpolated, emits a'' ... ''block (collect)"child": fold a repeated child’s first arg into aList. The only value form that reads a repeated child directly into a flat list. Usefor-eachwhen each item must produce distinct structure (a path per item) rather than a flat list. (Both are KDL type annotations:(type)value.)(collect-opt)"child": like(collect), but the wholesetis omitted when the child is empty, rather than emitting[ ]. Use it for an optional list-valued option whose absence should leave the NixOS default in place (e.g.services.openssh.ports, which defaults to[ 22 ]).(collect)still emits[ ]when empty.(secret)"name": a reference to a decrypted secret path, emittingconfig.<backend>.secrets."name".path. The name may interpolate bindings (e.g.(secret)"{k.secret}"). The backend is the project’ssecrets backend=setting (default sops-nix; the other value is agenix). knixl never sees the secret material: reference-only, no declaration and no name validation.(scalar)"{lookup}": emit a bound arg as its native Nix type (bool, int, or string), instead of stringifying it the way a plain"{lookup}"would. It takes exactly one{interpolation}and no surrounding literal text (anything else is an error). Use it to pass a user-chosen bool or int through faithfully, e.g.set "boot.zfs.forceImportRoot" (scalar)"{f.value}"wherevalueis atype="bool"arg emitsfalse, not"false".
A dotted option path where each segment is literal or interpolated:
- bare word (
services,nginx,forceSSL) ->AttrKey::Ident - quoted literal (
"/") ->AttrKey::Quoted, may itself interpolate - interpolation (
{host},{loc.match}) ->AttrKey::Quoted(a dynamic name)
This is where the oracle’s to_option_key() promise is kept: every Quoted segment collapses to <name> for option lookup, so services.nginx.virtualHosts."example.com".forceSSL matches the option services.nginx.virtualHosts.<name>.forceSSL.
Bindings tree
Section titled “Bindings tree”bind() walks the schema, not the raw node, so resolution is total and typed (validation already ran, so every referenceable name is present). Three shapes:
Scalar:"example.com",16,trueScope: a structured child, e.g.acme -> { email }, resolved with a dotted lookup{acme.email}List: a repeated child, in KDL source order
How each schema field maps:
- arg field ->
Scalar(positional value) - prop field ->
Scalar(key=value on the node) - flag child ->
Scalar::Bool(present-and-true) - scalar child ->
Scalar(child’s first arg) - structured child ->
Scopeover its own args/props - repeated child ->
Listof the above
Lookup resolution
Section titled “Lookup resolution”{host} resolves top-level. {acme.email} walks Scope("acme") then Scalar("email"). {loc.match} resolves the loop var loc (a Scope pushed by for-each) then Scalar("match").
Loop variables live in a separate LoopScopes stack, checked before top-level bindings, so for-each "host" in ... shadows the node’s host rather than colliding (the loader warns on shadow). Resolving a lookup to a non-Scalar in value position is a template authoring error, caught at module-load time by a dry type-pass, not at generation time.
Worked expansion (both list forms)
Section titled “Worked expansion (both list forms)”Input:
web-service "example.com" { upstream "http://127.0.0.1:3000" acme email="ops@example.com" alias "www.example.com" alias "example.org" location "/api" upstream="http://127.0.0.1:4000" location "/metrics" upstream="http://127.0.0.1:9090"}Template fragment:
set "services.nginx.virtualHosts.{host}.serverAliases" (collect)"alias"for-each "loc" in "location" { set "services.nginx.virtualHosts.{host}.locations.{loc.match}.proxyPass" "{loc.upstream}"}Emits (post-nixfmt):
services.nginx.virtualHosts."example.com".serverAliases = [ "www.example.com" "example.org"];services.nginx.virtualHosts."example.com".locations."/api".proxyPass = "http://127.0.0.1:4000";services.nginx.virtualHosts."example.com".locations."/metrics".proxyPass = "http://127.0.0.1:9090";collect gives the flat list, for-each gives one dynamic-keyed path per item. Both iterate in KDL source order, so the output hash is a pure function of the input, which is the property the lock depends on.
Migration notes (optional)
Section titled “Migration notes (optional)”A module may declare notes that knixl upgrade prints when it moves the module across a version. They live in a migrations block alongside schema and emit, keyed by the target version:
migrations { to "1.1.0" { note "enableACME now defaults on; drop any manual security.acme.certs entry per host." } to "1.2.0" { note "serverAliases is generated from the repeated `alias` children; remove any hand-written list." }}A step applies when its to version lands in the half-open range (recorded, running], so an upgrade from 1.0.0 to 1.2.0 shows both notes (ascending), while 1.1.0 to 1.2.0 shows only the last. The block is metadata: it does not affect emitted Nix or the output hash. Built-in modules provide the same notes through Module::migration_notes.
Built-in modules
Section titled “Built-in modules”Some modules are written in Rust because their logic exceeds what the declarative template grammar can express. Each claims a KDL node and owns its own output structure.
disko claims the disko node and generates NixOS disko configuration. It is built-in because disko’s config is name-keyed attribute sets nested several levels deep with heterogeneous per-partition content, which the single-level declarative template grammar cannot express.
Node shape: disk "<label>" device="<path>" holds partition "<name>" size="<size>" [type="<code>"] children. Each partition holds exactly one content child, which is one of:
filesystem format="<fmt>" mountpoint="<path>": a mounted filesystem. Repeatedmount-option "<opt>"children add to disko’smountOptionslist (e.g.mount-option "umask=0077"for an ESP).zfs pool="<name>": a ZFS member of the pool<name>. The pool itself must be declared aszpool "<name>"in the same node.swap [resume=#true]: a swap partition, optionally resuming to it.luks name="<name>"wrapping one inner content : LUKS encryption around a filesystem, zfs, or swap.
zpool "<label>" holds an optional mountpoint and zero or more dataset "<name>" [type="<type>"] [mountpoint="<path>"] children. Dataset type defaults to zfs_fs. Two optional prop-map children tune the pool: options key="value" ... (disko’s options, e.g. options ashift="12") and root-fs-options key="value" ... (disko’s rootFsOptions, e.g. root-fs-options compression="zstd" acltype="posixacl" xattr="sa" mountpoint="none"). Values are emitted as strings.
The preset="boot-root-zfs" pool="<name>" root-size="<size>" [boot-size="<size>"] [data-label="<name>"] shorthand is pure sugar for the common three-partition layout: an ESP (512M by default, or boot-size if set), an ext4 root (sized by root-size), and a ZFS vdev at 100% of the remaining space handed to pool. The data partition is named data by default; data-label renames it (older layouts used pool). It suits single-disk systems that keep the OS on ext4 and the data on ZFS.
Validation of disko.* paths (e.g. disko.devices.disk.main.device) runs only when the project declares disko as an out-of-tree oracle module via oracle-modules { module "disko" ... } in knixl.kdl. Without that declaration, disko paths remain unchecked (docs/06, ADR 0008).
incus claims the incus node and generates an Incus host. It is built-in because the daemon preseed is name-keyed attribute sets with optional per-network fields, plus a host firewall and an optional runtime API-listener oneshot, none of which the single-level declarative grammar can express.
Node shape: an optional ui flag, repeated storage-pool "<name>" driver= source=, network "<name>" type= ipv4= nat= [ipv6= ipv6-nat=], and profile "<name>" pool= network= children. A profile emits the default shape: a root disk on pool and an eth0 nic on network. Optional API listener: at most one of https-address "<host:port>" (a static core.https_address in the preseed) or https-address-from-interface "<iface>" (a systemd oneshot that binds core.https_address to that interface’s IPv4 at runtime, the tailnet-bind pattern). An optional firewall { } block holds repeated trust-interface "<iface>" (networking.firewall.trustedInterfaces) and open-api-on "<iface>" (opens the API port, networking.firewall.interfaces.<iface>.allowedTCPPorts, defaulting to 8443 or the port from a static https-address).
The module sets virtualisation.incus.enable = true, enables the web UI via virtualisation.incus.ui.enable when ui is set, and configures the daemon preseed from pools, networks, and profiles. Virtual machine support (via package "qemu") and administrative access (via the user module with group "incus-admin") are configured separately, which the incus module does not emit.
os claims the os node: core host configuration (identity, boot, and system tunables). It is built-in because it carries arbitrary-key attribute sets (boot.kernel.sysctl, nix.settings) and pkgs references (boot.kernelPackages, environment.systemPackages), which the declarative grammar cannot express.
Node shape (all children optional): state-version "25.11" (system.stateVersion), boot-loader "systemd-boot" (boot.loader.systemd-boot.enable; only systemd-boot today), efi-can-touch-variables #true (boot.loader.efi.canTouchEfiVariables), kernel-package "linuxPackages_6_18" (boot.kernelPackages = pkgs.<name>), timezone "Europe/London" (time.timeZone), locale "en_GB.UTF-8" (i18n.defaultLocale), mutable-users #false (users.mutableUsers), sysctl "<key>"=<value> ... (a prop map, boot.kernel.sysctl, values kept native), repeated kernel-module "<name>" (boot.kernelModules), repeated tmpfiles-rule (see below), repeated experimental-feature "<name>" and trusted-user "<name>" (nix.settings.experimental-features / trusted-users lists), nix-setting "<key>"=<value> ... (a prop map of scalar nix.settings.<key> entries), repeated system-package "<name>" (environment.systemPackages = [ pkgs.<name> ... ]), and session-variable "<NAME>"="<value>" ... (a prop map, environment.sessionVariables).
kernel-module and sysctl are a pair in practice: a sysctl often only exists once its module is loaded, so net.bridge.bridge-nf-call-iptables needs kernel-module "br_netfilter" alongside it to be settable at boot.
session-variable writes environment.sessionVariables, which NixOS puts in /etc/pam/environment for pam_env to load into every session, including a non-interactive ssh host cmd. environment.variables reaches interactive shells only, so it is not offered here. A name that is a valid bare Nix attribute renders unquoted (KDIR = "..."); anything else is quoted by the emitter.
tmpfiles-rule "<path>" type="<t>" [mode=] [user=] [group=] [age=] [argument=] emits one systemd.tmpfiles.rules line, with the fields named rather than positional, because the bare tmpfiles line is not readable. type is required; every other field defaults to -, tmpfiles’ own “leave this to the default” marker, so all seven columns are always present:
os { tmpfiles-rule "/var/lib/bench" type="d" mode="0755" user="wes" group="users" tmpfiles-rule "/tmp/scratch" type="d" mode="1777" age="10d"}systemd.tmpfiles.rules = [ "d /var/lib/bench 0755 wes users - -" "d /tmp/scratch 1777 - - 10d -"];Rules keep KDL source order, since tmpfiles applies them in order.
networking.hostName is NOT part of os: the host module sets it to the host’s label by default, overridable with a hostname "x" child on host (only host knows the label, and every host gets a hostName even with no os block). mutable-users here is the host-level half of enforcing a declarative password (the per-user hashedPassword lives on the user module).
guest "<name>" claims the guest node: a NixOS system container (containers.<name>) whose configuration is a nested knixl module tree (ADR 0011). It is built-in because it lowers a config { } block through the ordinary module registry (a guest is a mini-host) and re-roots every resulting assignment under containers.<name>.config, which no fixed-path module can do.
Node shape: a required name, envelope children autostart/ephemeral/private-network (bool flags), host-address/local-address (strings), repeated bind-mount "/mount" host-path="..." [read-only=#true], and a config { } block holding ordinary knixl module nodes (os, user, openssh, web-service, …). Envelope children map to containers.<name>.<opt> (autoStart, privateNetwork, hostAddress, bindMounts, …); the config block’s modules are lowered and re-rooted, so web-service inside a guest emits containers.<name>.config.services.nginx.enable = true.
Paths inside containers.*.config are exempt from oracle validation: nixosOptionsDoc types the container config as one submodule, so its interior is not in the flat option set (ADR 0011), the same opaque treatment raw-nix gets. In v1 the config block re-roots normal (Bucket::Default) assignments only; a nested module that emits a side-file or raw-nix is rejected.
nix-ld
Section titled “nix-ld”nix-ld claims the nix-ld node: running prebuilt dynamically linked binaries (programs.nix-ld). It is built-in because the library list is pkgs references, which the declarative grammar cannot express (interpolation stringifies).
It comes up whenever a host runs binaries it did not build: a rustup toolchain a project pins for itself, or prebuilt binaries from a GitHub release. /lib64/ld-linux-x86-64.so.2 on NixOS is stub-ld, so without nix-ld none of them execute at all.
Node shape: repeated library "<name>", each a pkgs attr, dotted names allowed. The node’s presence is the opt-in, so there is no enable child:
nix-ld { library "stdenv.cc.cc.lib" library "zlib"}programs.nix-ld.enable = true;programs.nix-ld.libraries = [ pkgs.stdenv.cc.cc.lib pkgs.zlib];The library list is the part worth having typed: it is per-host, discovered by running ldd against the actual binaries, and it is what you come back and edit. Libraries keep KDL source order.
Declarative modules shipped with knixl
Section titled “Declarative modules shipped with knixl”These are authored in the grammar above, not in Rust. They are embedded in the binary from crates/knixl-modules/stdlib/<name>/knixl-module.kdl (the stdlib), so they are available in any project with no local files. A project can still shadow one by dropping a modules/<name>/knixl-module.kdl of its own.
zfs claims the zfs node and enables ZFS with the mandatory host id. Node shape: zfs "<host-id>" (an 8 hex-digit machine id, required because ZFS refuses to import a pool whose host id does not match) with optional auto-scrub (a bool flag), repeated extra-pool "<name>" (imported at boot), an at-most-one arc-max-bytes <n> child, and an at-most-one force-import-root <bool> child.
It sets networking.hostId, boot.supportedFilesystems.zfs = true, collects extra-pool into boot.zfs.extraPools, enables services.zfs.autoScrub when auto-scrub is set, caps the ARC via boot.extraModprobeConfig when arc-max-bytes is given, and sets boot.zfs.forceImportRoot (via (scalar), so force-import-root #false emits false, not "false") when force-import-root is given.
user claims the user node: a normal login user. Node shape: user "<name>" with an at-most-one description "<text>", repeated group "<name>" (supplementary groups), repeated ssh-key "<key>", and an at-most-one hashed-password "<hash>" child.
It sets users.users.<name>.isNormalUser = true, collects groups into extraGroups, authorised keys into openssh.authorizedKeys.keys, and sets hashedPassword when hashed-password is given (a mkpasswd hash, so no plaintext). Enforcing a declarative password also needs users.mutableUsers = false, which is a host-level tunable, not a per-user one.
openssh
Section titled “openssh”openssh claims the openssh node: a hardened OpenSSH server (password and keyboard-interactive auth off, public-key auth on). Node shape: repeated port <n>, an at-most-one permit-root "<value>" (e.g. "prohibit-password"), and an optional x11-forwarding flag.
It sets services.openssh.enable = true, forces PasswordAuthentication/KbdInteractiveAuthentication off, collects port into services.openssh.ports (omitted leaves the NixOS default [ 22 ]), and applies PermitRootLogin/X11Forwarding when given.
tailscale
Section titled “tailscale”tailscale claims the tailscale node and generates NixOS tailscale configuration. Node shape: an optional open-firewall (a bool flag), optional up-flag children hold flags passed to tailscale up (e.g. up-flag "--ssh", up-flag "--operator=alice"), and an auth-key secret="name" child wires services.tailscale.authKeyFile to a named secret via (secret).
The module sets services.tailscale.enable = true, sets services.tailscale.openFirewall = true when open-firewall is given, collects up-flag children into services.tailscale.extraUpFlags as a list, and wires the auth-key secret reference to services.tailscale.authKeyFile. If no auth-key is declared, authKeyFile is not set, leaving interactive login as the fallback.
home-manager
Section titled “home-manager”home-manager claims the home-manager node and configures home-manager for one user (as a NixOS module). Node shape: home-manager "<user>" state-version="<rel>" with repeated session-var "<name>" "<value>" and program "<name>" children. Each program child enables programs.<name>.
The module sets home.stateVersion (required), collects session-var children into home.sessionVariables (sourced only in home-manager-managed shells; display managers and non-login shells do not see them), and always sets useUserPackages and useGlobalPkgs to true for safe NixOS integration. It never emits nix.gc or nix.settings (left to NixOS). In v1, only one home-manager node per host is supported; multi-user and home.packages are follow-ups.
Validation of home-manager.* paths and the home-manager NixOS module import run only when the project declares home-manager as an oracle module via oracle-modules { module "home-manager" ... } in knixl.kdl, like disko or sops.
