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:
| 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 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:
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."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
StartLimitBurstandStartLimitIntervalSecwhen usingRestart = "on-failure". - Use
requires/after/partOfamong 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:
- The plugin’s skills, written into the agent’s
~/.config/cowboy/skills/. pluginClion the agent’s PATH.- The agent’s polkit-managed unit list (
managedServices.units) so it can cycle the stack itself.
Managed units must exist. Every name in
managedServices.unitsmust 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.nixchecks this at eval time and warns rather than asserts (services.cowboy.managedServices.validateUnits, defaulttrue), 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’splugins.<name>.unitslist is informational and is not validated.
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, built once indefault.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, useforAgentsWhere. - Register in
services.cowboy.plugins.<name>so the plugin is discoverable. - Every unit in
managedServices.unitsactually exists (the validator warns otherwise);plugins.<name>.unitsis 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.