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-09-06

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 in-tree Home Assistant skill is the worked example of per-agent content; the examples below use a hypothetical myplugin that runs a service on the host and teaches an agent to drive it.

Everything below is grounded in the implementation under modules/.

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 myplugin this maps to: services.nix (infrastructure), default.nix (packages, built once and imported by both of the others), and the config block in module.nix guarded by hasCowboy (integration). Disable cowboy and the service still runs.

The plugin contract

1. Own your namespace

Declare options under your own top-level namespace, never inside services.cowboy.*:

# Good
options.services.myplugin = { enable = ...; dataDir = ...; };

# Bad — destabilizes cowboy's option tree and breaks standalone use
options.services.cowboy.myplugin = { ... };

2. Read cowboy state via cowboyLib

Cowboy injects a cowboyLib module argument (modules/lib/default.nix). This is the supported module interface today, not a versioned binary ABI or a promise of compatibility across future revisions. Pin the Cowboy input and evaluate extensions when updating it. 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.serviceUser            # cowboy's service-account prefix / shared bridge group
cowboyLib.userFor / homeFor name # agent user / home dir by name
cowboyLib.caBundleFor name       # the trust store the agent's TLS clients must use ($SSL_CERT_FILE)
cowboyLib.inNamespace            # is agent traffic proxied?

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."myplugin-setup" = { ... };

  # Integration — only with cowboy
  services.cowboy.skills = lib.mkIf hasCowboy { ... };

  home-manager.users = lib.mkIf hasCowboy (
    cowboyLib.forAgents (acfg: { home.packages = [ pluginCli ]; })
  );
};

4. Don’t block cowboy.target

Plugin services join the agent lifecycle with a soft pull-in:

  • Use wantedBy = [ "cowboy.target" ] for startup with the target. Add stop and restart coupling only when the plugin needs that lifecycle.
  • Bound restart loops with StartLimitBurst and StartLimitIntervalSec when using Restart = "on-failure".
  • Use requires/after/partOf among the plugin’s own services for internal ordering.

5. Keep packages self-contained

callPackage your own dependencies, and build each one once — a default.nix that both module.nix and services.nix import, rather than two copies of the same derivation kept in sync by hand. 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.myplugin-guide = {
  description = "Drive myplugin from the CLI";
  prompt = ./skills/myplugin-guide.md;  # or promptText = "...inline...";
  requires = [ pluginCli ];             # installed for every targeted agent
  additionalTools = [ "bash" "read" "write" ];
  tags = [ "myplugin" "control" ];
  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.myplugin-eval = {
  package = pluginCli;
  command = "myplugin run {{script}}";  # placeholders are {{double-brace}}, NOT {single}
  description = "Run a myplugin 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). A plugin can also expose a capability through a skill alone (prompt + its binary on PATH), which is the lighter path when the agent only needs a CLI.

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.myplugin = {
  description = "Host service control";
  version = "1";
  extensionPoints = [ "skills" "units" ];
  units = [ "myplugin-setup.service" "myplugin-daemon.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
  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 default to dedicated service accounts. Keep their identities separate from agents and the operator when overriding user. Configure outbound approval on the bridge and inbound routing with routes / defaultAgent. The Security Model explains the separate Redis authorities.

The systemd target

A unit can be started with the agent target:

systemd.services.my-capability.wantedBy = [ "cowboy.target" ];

Per-agent skills: the Home Assistant pattern

The global skills registry distributes to all agents by default and supports an agents selector. For content that differs per agent, 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 handles per-agent selection directly in the registry, so a plain skill that only needs targeting does not need this bypass. HA 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: myplugin end to end

myplugin/
├── module.nix      # options + integration layer (skills, agent packages, registry entry)
├── services.nix    # infrastructure: myplugin-setup / myplugin-daemon units
├── default.nix     # packages: the CLI and its client library (callPackage), built once
└── skills/*.md     # skill prompts

Consumer wiring (machines/<host>.nix):

imports = [
  inputs.cowboy.nixosModules.default
  ../modules/myplugin/module.nix
];

services.myplugin = {
  enable = true;
  dataDir = "/home/<user>/myplugin";
  user = "<user>";
};

What lights up because cowboy is present:

  1. The plugin’s skills, written into the agent’s ~/.config/cowboy/skills/.
  2. pluginCli on the agent’s PATH.
  3. The agent’s polkit-managed unit list (managedServices.units) so it can cycle the stack itself.

Managed units must exist. Every name in managedServices.units must be a unit the configuration declares; a polkit rule over a unit that does not exist advertises a control that silently does nothing. modules/services.nix checks this at eval time and warns rather than asserts (services.cowboy.managedServices.validateUnits, default true), because a host’s units may be legitimately conditional — a feature flag, an optional submodule — and a hard assertion would force every consumer to mirror that logic into its unit list. The registry’s plugins.<name>.units list is informational and is not validated.

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, built once in default.nix; not assumed present.
  • Plugin services declare startup dependencies and bounded restart behavior.
  • 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 actually exists (the validator warns otherwise); plugins.<name>.units is informational.

Different contracts

The Nix module interface configures deployments. The component interface is separately versioned as cowboy:agent@0.2.0; its WIT source defines commands, effects and carriers. The JSON inside a snapshot remains an opaque display model, not a versioned presentation schema. Native control and descriptor records have their own CDDL definitions.

The A2A adapter exposes a limited profile over the root agent. It does not provide general A2A task persistence, peer addressing or every protocol method. See the Component ABI and A2A profile.