Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


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:

LayerWhat it isCouples to cowboy?
Infrastructuresystemd services / scripts providing the raw capability (game server, DB, hardware)No — runs standalone
Packagesderivations giving the programmatic interface (Python env, CLI) — built via callPackageNo — self-contained
Harness integrationconditional blocks registering skills/tools/bridges and pushing packages into agent homesYes — 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: forAgents and the skills/tools registries fan out to all enabled agents by default. To scope a skill/tool to specific agents, set its agents = [ "name" … ] (empty = all). For per-agent content (prompts that differ by agent), use forAgentsWhere directly — 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 use partOf against the target — a failing plugin service would tear the whole target down.
  • On any service with Restart = "on-failure", set StartLimitBurst and StartLimitIntervalSec. A restart loop without these blocks switch-to-configuration during a rebuild.
  • Use requires/after/partOf among 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:

  1. Four skills (ksp-pilot, mission-plan, ksp-brief, ksp-marimo) written into the agent’s ~/.config/cowboy/skills/.
  2. krpcPython, xdotool, uv (+ marimo helpers) on the agent’s PATH.
  3. 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-stream script (a writeShellScriptBin in services.nix), which spawns Xvfb, KSP, the harness, marimo and OBS together — not by per-unit systemd services. The mun-ksp.service / mun-stream.service names that appear in lambda.nix’s managedServices.units do not correspond to real units on the host. See Improvements §3.

Checklist for a new plugin

  • Options under your own namespace, with an enable flag.
  • cowboyLib accepted with a { enabledAgents = {}; forAgents = _: {}; } fallback.
  • All cowboy registration guarded by hasCowboy.
  • Packages via callPackage; not assumed present.
  • Plugin services use wantedBy = [ "cowboy.target" ] + StartLimit*, never partOf the 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, use forAgentsWhere.
  • Register in services.cowboy.plugins.<name> so the plugin is discoverable.
  • Every unit in managedServices.units / plugins.<name>.units actually exists (the validator warns otherwise).