title: Plugin System — Improvements tags: [architecture, plugins, roadmap] created: 2026-06-12
Plugin System — Improvements
The plugin architecture works and has a real consumer
(mun.nix), but the contract had sharp edges. Each item below is a
concrete gap observed in the code, with the evidence, the impact, and the
fix.
Status (2026-06-12): all six landed. The contract changes are documented in plugins.md. One deliberate deviation from the original plan: #3’s check ships as a build-time warning, not a hard assertion — see that section for why. Implementation notes are inline below each item.
1. Per-agent targeting for plugin-supplied skills and tools
The gap. cowboyLib.forAgents, services.cowboy.skills, and
services.cowboy.tools all fan out to every enabled agent. A plugin
cannot say “this skill is for the research agent only.” The system
already supports multiple agents (services.cowboy.agents.<name>,
mode = "multi"), so this is a real limitation, not a hypothetical.
Evidence. modules/skills/home-assistant.nix exists solely to work
around this. Its header comment says it all:
# Bypasses global cfg.skills registry to install only into HA-enabled agents
It reimplements the registry’s home-manager fan-out by hand, filtering on
a per-agent option (agentOpts.skills.homeAssistant.enable). Every plugin
that wants per-agent scoping must copy this boilerplate.
Impact. In single-agent setups (lambda today) it’s harmless. The
moment a second agent appears, every globally-registered skill/tool leaks
to it — including ones that carry capability (extra additionalTools,
packages on PATH). This is the same single-agent assumption flagged
throughout the module set.
Fix (landed). cowboyLib gained forAgentsWhere pred f and
agentNames (modules/lib/default.nix), and both skills and tools
gained an agents = [ … ] selector (empty = all enabled agents,
back-compat). The consumer fan-outs build a per-agent skill/tool set
(skillsFor / toolsFor) and a per-agent skills.json / tools.json;
the per-agent tools manifest also feeds the per-agent WASM override. All
pure Nix — the harness already reads per-agent files.
Correction to the original claim: home-assistant.nix does not
fully collapse into a registry entry. The agents selector handles
per-agent selection, but HA’s prompt is per-agent content (endpoint /
token / managed units differ per agent), which a selection-only list can’t
express. HA stays as the worked example of forAgentsWhere for
content-templated skills.
2. On-demand skill requires are never installed
The gap. modules/skills/default.nix installs a skill’s requires
packages into home.packages only when autoLoad = true:
home.packages = lib.concatLists (
lib.mapAttrsToList (name: skill:
if skill.autoLoad then skill.requires else [ ] # <-- on-demand skills get nothing
) cfg.skills
);
The plugins doc previously claimed
“the requires packages are added to each agent’s home.packages” — true
only for the auto-loaded subset.
Impact. An on-demand skill’s prompt tells the agent to run a tool that
isn’t on PATH. The agent discovers the gap at runtime, mid-task. mun.nix
sidesteps it by pushing krpcPython via forAgents regardless of skill
state — meaning the requires field is effectively decorative for its
on-demand skills.
Fix (landed). Option (a): modules/skills/default.nix now installs the
requires of every skill targeting an agent, regardless of autoLoad
(home.packages dedups by store path, so this is cheap). mun.nix no
longer needs to push krpcPython by hand — though it still does so for
tools outside the skill set (xdotool, uv), which is fine.
3. Phantom systemd units in managedServices
The gap. machines/lambda.nix grants the agent polkit rights over
managedServices.units = [
... "mun-ksp.service" "mun-xvfb.service" "mun-setup.service"
"mun-stream.service" "mun-marimo.service"
];
but mun.nix/services.nix only ever defines one of these
(mun-setup). KSP/stream/marimo are launched by the mun-stream shell
script, not by units. The other four .service names refer to nothing.
Impact. Polkit rules are generated for units that don’t exist. The
agent’s mental model (and the HA-style skill prompt that lists managed
units) advertises controls that silently do nothing. It also means the
doc’s own example — which shows mun-ksp/mun-stream as proper services
with StartLimit* — never matched reality.
Proposal. Pick one model and make it true:
- Service model (recommended): convert the
mun-streamscript into realmun-ksp/mun-stream/mun-marimounits with thewantedBy = [ "cowboy.target" ]+StartLimit*discipline the contract already requires. ThenmanagedServices.unitsis honest and the agent cansystemctl restart mun-kspas advertised. - Script model: drop the phantom units from
managedServicesand give the agent a documentedmun-streaminvocation instead.
Either way, validate that every managedServices.units entry resolves to a
declared unit.
Fix (landed) — prune + warn (not assert). The phantom mun-* units
(plus always-phantom steam.service / obs.service) were pruned from
machines/lambda.nix; mun.nix keeps only its real units
(mun-setup / mun-mods) and the launch path (mun-stream script) is
documented. modules/services.nix gained a suffix-aware unit-existence
check across services/targets/timers/sockets/mounts/paths,
gated by managedServices.validateUnits (default true).
Why a warning, not the planned hard assertion: lambda’s media units are
declared conditionally — plex.nix is imported only under
sensitive.lib.sellout, readarr further depends on a books flag, and
transmission is currently disabled. A hard assertion would break the
spoof/template build (where those units legitimately don’t exist) and would
force every consumer to mirror that conditional logic into its unit list. A
warning surfaces the bug without that fragility. (The validator promptly
earned its keep: it flags transmission.service on lambda, which is listed
in managedServices but not currently a declared unit — left in place
pending a decision to enable transmission or drop the entry.)
4. cowboyLib is too thin to discourage reaching into internals
The gap. cowboyLib exposes only enabledAgents and forAgents.
Anything else a plugin needs — the broker user, the netns/proxy state,
whether ACLs are on, per-agent home dirs by name — it reads straight out
of config.services.cowboy.*. That recouples plugins to cowboy’s internal
layout, the exact thing the namespace rule (contract §1) tries to prevent.
Impact. Plugins break when cowboy refactors internals. The [managedServices/daemon/sheepdog scoping refactor already on the books] would move several options; every plugin reading them directly is collateral.
Proposal. Treat cowboyLib as the stable plugin ABI and widen it
deliberately:
cowboyLib = {
inherit enabledAgents forAgents;
broker = cfg.broker;
homeFor = name: enabledAgents.${name}.homeDirectory;
userFor = name: enabledAgents.${name}.user;
inNamespace = cfg.secretsProxy.enable; # is traffic proxied?
# ... a curated, documented surface
};
Document it as the contract; everything else under services.cowboy.*
is internal and may change without notice.
Fix (landed). modules/lib/default.nix now exposes forAgentsWhere,
agentNames, broker, userFor/homeFor, and inNamespace alongside
the existing enabledAgents/forAgents/singleAgent, documented in
plugins.md as the stable
ABI.
5. No plugin-side validation or discovery
The gap. Plugins are wired purely by manual imports in the machine
config. There’s no assertion surface that a plugin can hook to validate
its assumptions (e.g. “stream.enable requires an NVIDIA GPU and a twitch
key path that exists”), and no registry of “installed plugins” the agent
or operator can introspect.
Impact. Misconfiguration fails late (at activation or, worse, at
runtime in the mun-stream script’s preflight command -v checks rather
than at eval time).
Proposal. Lightweight, additive:
- Encourage
assertionsin plugin modules (none of the current option files assert their cross-field invariants). - Optionally, a
services.cowboy.plugins.<name>informational registry a plugin sets ({ description, version, extensionPoints }) so the agent’s context and acowboy pluginsCLI subcommand can enumerate what’s installed. Cheap, and turns “what can this agent do here” into a query.
Fix (landed). services.cowboy.plugins.<name>
(modules/options/plugins.nix) is the registry; modules/plugins.nix
emits /etc/cowboy/plugins.json; the per-agent tools.json
system_prompt_suffix surfaces the list to the agent with zero Rust
changes; and cowboy plugins (cowboy/cli.py) prints it for operators.
mun.nix self-registers. The “encourage assertions” half is seeded with a
representative warnings entry in mun.nix (streaming without marimo
leaves a blank dashboard panel).
6. Duplicated package construction
The gap. mun.nix calls pkgs.callPackage ./krpc.nix {} and builds
krpcPython in both module.nix and services.nix. Same closure,
defined twice, kept in sync by hand.
Impact. Minor — Nix dedups the store path — but it’s a maintenance
trap (edit one, forget the other) and a bad example for plugin authors who
copy mun as the template.
Fix (landed). nix/modules/mun.nix/packages.nix defines krpcPython,
krpcPkg, and marimoPairPkg once; module.nix and services.nix both
inherit from import ./packages.nix { inherit pkgs; }. This also makes
the “Packages layer” from the contract a real file.
Status
| # | Item | Payoff | Status |
|---|---|---|---|
| 3 | Phantom managed units | correctness — agent controls that lie | Done — pruned + validator warning |
| 2 | On-demand requires not installed | correctness — runtime PATH gaps | Done — always installed |
| 6 | Duplicated package construction | hygiene / better template | Done — packages.nix |
| 1 | Per-agent skill/tool targeting | unblocks multi-agent | Done — agents selector (HA stays for content) |
| 4 | Widen cowboyLib ABI | decouples plugins from refactors | Done — widened |
| 5 | Plugin validation + discovery | fail-early + introspection | Done — registry + cowboy plugins |
Follow-ups left open: decide transmission.service’s fate on lambda
(enable or drop); consider promoting the unit validator to a hard assertion
on hosts whose unit lists are fully static; and the lazy/ephemeral
requires install (#2 option b) if PATH closure size ever matters.