title: Plugin Architecture tags: [architecture, plugins, extensibility] created: 2026-04-24 updated: 2026-06-12
Plugin Architecture
Cowboy’s NixOS module system is the extension surface. An external module
(a “plugin”) adds capability to agents by registering into cowboy’s
option tree rather than forking the core. The canonical example is
mun.nix — a Kerbal Space Program
- Twitch streaming stack that teaches an agent to fly rockets — and the in-tree Home Assistant skill.
Everything below is grounded in the actual implementation. Where the code and the ideal diverge, that’s called out and tracked in Plugin Improvements.
The three layers
A plugin separates into layers so that only the top one couples to cowboy:
| Layer | What it is | Couples to cowboy? |
|---|---|---|
| Infrastructure | systemd services / scripts providing the raw capability (game server, DB, hardware) | No — runs standalone |
| Packages | derivations giving the programmatic interface (Python env, CLI) — built via callPackage | No — self-contained |
| Harness integration | conditional blocks registering skills/tools/bridges and pushing packages into agent homes | Yes — guarded by hasCowboy |
In mun.nix this maps to: services.nix + ckan.nix (infrastructure),
krpc.nix / marimo-pair.nix via callPackage (packages), and the
config block in module.nix guarded by hasCowboy (integration).
Disable cowboy and you still have a working KSP streaming rig.
The plugin contract
1. Own your namespace
Declare options under your own top-level namespace, never inside
services.cowboy.*:
# Good
options.services.mun = { enable = ...; gameDir = ...; };
# Bad — destabilizes cowboy's option tree and breaks standalone use
options.services.cowboy.ksp = { ... };
2. Read cowboy state via cowboyLib
Cowboy injects a cowboyLib module argument
(modules/lib/default.nix). This is the stable plugin ABI — everything
under services.cowboy.* that isn’t surfaced here is internal and may
change. Members:
cowboyLib.enabledAgents # attrset of enabled agents (name -> agentConfig)
cowboyLib.agentNames # [ "agent" ... ] — for building `agents = [ ... ]` selectors
cowboyLib.forAgents (acfg: {…}) # map an HM config fn over every enabled agent, keyed by user
cowboyLib.forAgentsWhere pred f # …only agents matching `pred name acfg` (generic haAgents)
cowboyLib.singleAgent "context" # the one enabled agent, or a lazy throw under multi
cowboyLib.broker # human operator user (may be null)
cowboyLib.userFor / homeFor name # agent user / home dir by name
cowboyLib.inNamespace # is agent traffic proxied? (was: read secretsProxy.enable)
Accept it with a fallback so the plugin still evaluates when cowboy is absent:
{ config, lib, pkgs,
cowboyLib ? { enabledAgents = {}; forAgents = _: {}; },
... }:
let hasCowboy = cowboyLib.enabledAgents != {}; in
Per-agent targeting:
forAgentsand theskills/toolsregistries fan out to all enabled agents by default. To scope a skill/tool to specific agents, set itsagents = [ "name" … ](empty = all). For per-agent content (prompts that differ by agent), useforAgentsWheredirectly — see the Home Assistant pattern.
3. Guard harness integration
Keep infrastructure unconditional; gate cowboy registration on hasCowboy:
config = lib.mkIf cfg.enable {
# Infrastructure — always
systemd.services."mun-setup" = { ... };
# Integration — only with cowboy
services.cowboy.skills = lib.mkIf hasCowboy { ... };
home-manager.users = lib.mkIf hasCowboy (
cowboyLib.forAgents (acfg: { home.packages = [ krpcPython ]; })
);
};
4. Don’t block cowboy.target
Plugin services join the agent lifecycle with a soft pull-in:
- Use
wantedBy = [ "cowboy.target" ]. Do not usepartOfagainst the target — a failing plugin service would tear the whole target down. - On any service with
Restart = "on-failure", setStartLimitBurstandStartLimitIntervalSec. A restart loop without these blocksswitch-to-configurationduring a rebuild. - Use
requires/after/partOfamong the plugin’s own services for internal ordering.
5. Keep packages self-contained
callPackage your own dependencies. Don’t assume cowboy provides any
particular package on PATH (the base set is just bash coreutils ripgrep fd jq gh nix git curl, plus camoufox’s browser — see modules/tools.nix).
Extension points
Skills — knowledge + prompt + packages
A skill is a markdown prompt the agent can load, optionally with packages
and extra tools. Registered into the global services.cowboy.skills
attrset (modules/skills/default.nix):
services.cowboy.skills.ksp-pilot = {
description = "Control KSP vessels via kRPC";
prompt = ./skills/ksp-pilot.md; # or promptText = "...inline...";
requires = [ krpcPython ]; # see the gotcha below
additionalTools = [ "bash" "read" "write" ];
tags = [ "ksp" "control" "krpc" ];
autoLoad = false; # true = loaded at agent startup
agents = [ ]; # [] = all enabled agents; or [ "pilot" ]
};
For each agent the skill targets, the module writes
~/.config/cowboy/skills/<name>.md (the harness discovers skills by
reading this directory) plus a per-agent skills.json, and installs the
skill’s requires packages onto that agent’s PATH — regardless of
autoLoad, so an on-demand skill’s binaries are present when the agent
loads it. The agents selector filters which agents receive the skill.
Tools — schema’d executable capabilities
A tool is a command template the harness can call with structured args
(modules/tools.nix). The default set (bash, web-search,
spawn_subagent) merges with anything a plugin adds:
services.cowboy.tools.krpc-eval = {
package = krpcPython;
command = "python3 {{script}}"; # placeholders are {{double-brace}}, NOT {single}
description = "Run a kRPC control script";
sandbox = "standard"; # "none" | "standard" | "strict"
timeout = 60;
agents = [ ]; # [] = all enabled agents (defaults always reach every agent)
schema = {
type = "object";
properties.script = { type = "string"; description = "Path to script"; };
required = [ "script" ];
};
};
Tools are baked per-agent into the harness WASM and written to a per-agent
~/.config/cowboy/tools.json; the agents selector scopes a tool to
specific agents (the default tools — bash, web-search,
spawn_subagent — leave it empty, so they reach everyone). No first-party
plugin currently registers a custom tool — mun exposes KSP control
through a skill (prompt + krpcPython on PATH) instead, so the tool
path is the less-trodden one.
Plugin registry — be discoverable
Register the plugin in the informational registry so the agent (via its
system prompt) and the operator (cowboy plugins / /etc/cowboy/plugins.json)
can enumerate what’s installed:
services.cowboy.plugins.mun = {
description = "KSP control + Twitch streaming";
version = "1";
extensionPoints = [ "skills" "units" ];
units = [ "mun-setup.service" "mun-mods.service" ]; # the real units it owns
agents = [ ]; # [] = surfaced to all agents
};
This is purely descriptive — it wires up no capability, it just makes the
plugin enumerable. The per-agent tools.json system_prompt_suffix
carries the list to the agent with no harness changes.
Bridges — bidirectional message channels
A bridge connects an external platform to the agent’s pubsub
(modules/bridges.nix, options in modules/options/bridges.nix). Each
declaration auto-generates a pubsub source plus three systemd units
(cowboy-<name>-{ping,ingest,outbox}) under cowboy-bridges.target:
services.cowboy.bridges.discord = {
pkg = myBridgePkg; # provides bin/discord-{ping,ingest,outbox}
env = "/run/agenix/discord-env"; # EnvironmentFile
user = config.services.cowboy.broker; # defaults to secretsProxy.broker
icon = "💬";
approval = {
required = true;
notify = "discord"; # which outbox sends the approval prompt
notify_channel = "1489..."; # channel id within that outbox
timeout = 3600; # auto-reject after N seconds
};
};
Bridges run as the broker (a human-operated trust domain), not as the
agent — gating is via approval + per-agent tool allowlists, not
per-agent bridge config.
The systemd target
Any unit can join the agent’s start/stop lifecycle:
systemd.services.my-capability.wantedBy = [ "cowboy.target" ];
Per-agent skills: the Home Assistant pattern
The global skills registry distributes to all agents. When a
capability should reach only some agents, the in-tree HA skill
(modules/skills/home-assistant.nix) shows the escape hatch: it
bypasses the registry and writes the prompt directly into the homes of
agents that opted in via a per-agent option (agentOpts.skills.homeAssistant):
let haAgents = lib.filterAttrs (_: a: a.skills.homeAssistant.enable) enabledAgents; in
home-manager.users = lib.mapAttrs' (_: acfg:
lib.nameValuePair acfg.user {
home.file.".config/cowboy/skills/home-assistant.md".source = mkHaSkillPrompt acfg;
home.packages = [ pkgs.curl pkgs.jq ];
}
) haAgents;
The agents selector now handles per-agent selection directly in the
registry, so a plain skill that only needs targeting no longer needs this
bypass. HA still uses it because its prompt is per-agent content — the
endpoint, token, and managed units differ per agent — which a
selection-only list can’t express. Use cowboyLib.forAgentsWhere for that
case; HA is the worked example.
Worked example: mun.nix end to end
mun.nix/
├── module.nix # options + integration layer (skills, HM packages, OBS scene)
├── services.nix # infrastructure: mun-setup bind-mount + mun-stream/mun-screenshot scripts
├── ckan.nix # infrastructure: declarative mod install
├── krpc.nix # package: kRPC python client (callPackage)
├── marimo-pair.nix # package: marimo helpers (callPackage)
└── skills/*.md # skill prompts
Consumer wiring (machines/lambda.nix):
imports = [
inputs.cowboy.nixosModules.default
../modules/mun.nix/module.nix
];
services.mun = {
enable = true;
gameDir = "/home/dylan/.local/share/Steam/steamapps/common/Kerbal Space Program";
user = "dylan";
stream.enable = true;
marimo.enable = true;
};
What lights up because cowboy is present:
- Four skills (
ksp-pilot,mission-plan,ksp-brief,ksp-marimo) written into the agent’s~/.config/cowboy/skills/. krpcPython,xdotool,uv(+ marimo helpers) on the agent’s PATH.- The agent’s polkit-managed unit list (
managedServices.units) so it can cycle the stack itself.
Launch note: the running stack is started by the
mun-streamscript (awriteShellScriptBininservices.nix), which spawns Xvfb, KSP, the harness, marimo and OBS together — not by per-unit systemd services. Themun-ksp.service/mun-stream.servicenames that appear inlambda.nix’smanagedServices.unitsdo not correspond to real units on the host. See Improvements §3.
Checklist for a new plugin
- Options under your own namespace, with an
enableflag. -
cowboyLibaccepted with a{ enabledAgents = {}; forAgents = _: {}; }fallback. - All cowboy registration guarded by
hasCowboy. - Packages via
callPackage; not assumed present. - Plugin services use
wantedBy = [ "cowboy.target" ]+StartLimit*, neverpartOfthe target. - A skill’s binaries go in its
requires(installed for every targeted agent, on-demand or not). - Scope to specific agents with
agents = [ … ]; for per-agent content, useforAgentsWhere. - Register in
services.cowboy.plugins.<name>so the plugin is discoverable. - Every unit in
managedServices.units/plugins.<name>.unitsactually exists (the validator warns otherwise).