Declarative, persistent Claude Code agents as a home-manager module.
clawde supervises long-lived Claude Code sessions as tmux windows, each driven by an optional cron heartbeat, exposed over channel adapters (Discord) and peer adapters (A2A), with per-agent personality, model, permission mode, skill directories, and agent-type defaults. A single service reconciles every declared agent on the host.
Add the flake as an input and import its home-manager module:
{inputs.clawde.url="github:castrozan/clawde";inputs.clawde.inputs.nixpkgs.follows="nixpkgs";}{inputs, ... }:
{imports=[inputs.clawde.homeManagerModules.default];clawde.claudePackage=pkgs.claude-code;clawde.agents.my-agent={type="generic";personality="You are my-agent.";};}Keep inputs.clawde.inputs.nixpkgs.follows = "nixpkgs" set: clawde's input set is
deliberately nixpkgs-only, so following your nixpkgs keeps the downstream lock free of
clawde-specific entries.
The consuming configuration injects the host-specific wiring the module does not own:
clawde.claudePackage- the claude-code package agents launch.clawde.machinesRegistry- fleet topology keyed by host alias (platform and, for the steward, tailscaleIp/username), used to buildfleet.jsonandhost-identity.json.clawde.dotfilesRepoPath- the checkout this fleet member tracks (defaults to ~/.dotfiles).clawde.stewardLiveCheckoutPayloadPath- optional live editable steward payload path; null symlinks the immutable in-store payload.healthCheckLib(module arg) - optional; when present, clawde contributes health probes.
An agent declared with onDemand = true is never brought up by the supervisor on its
own. It holds no process and no multiplexer window until an operator starts it:
clawde.agents.my-agent={type="project-manager";onDemand=true;idleTimeoutMinutes=30;};clawde start my-agent
clawde stop my-agent
clawde list
clawde with no arguments starts the supervised agents session, and
clawde --help, clawde -h, or clawde help prints the top-level usage
naming every command and its purpose. Running it with a command it does not
know fails without touching the supervisor, the multiplexer, or the platform
service manager.
The module installs a bash completion at
$XDG_DATA_HOME/bash-completion/completions/clawde, which bash-completion's dynamic
loader searches first, so the subcommands and the help forms complete,
start/stop offer only the agents declared onDemand, and active offers
only the agents that actually have an active-hours gate. It goes to the user data directory rather than the package's share
because nix-darwin's per-user profile does not link share/bash-completion onto
XDG_DATA_DIRS.
clawde start writes a lease under ~/clawde/on-demand/<agent>.json and the supervisor
brings the agent up on its next poll. The lease survives until the agent's session
transcript has been silent for idleTimeoutMinutes, at which point the supervisor tears
the agent down through the same path it uses for an agent outside its active hours. The
idle clock is floored at the moment the lease was granted, so starting an agent whose
last conversation was days ago does not stop it immediately.
The agent's session record outlives the teardown, so the next clawde start resumes the
same conversation rather than making the operator find it. Set dailySessionRotation
if a fresh session per day is wanted instead. activeHoursStart/activeHoursEnd still
apply on top: an on-demand agent outside its active hours stays stopped despite a lease.
An agent that answers Discord messages without holding a warm harness process runs the bridge as an always-connected sidecar and lets the supervisor wake the harness for one headless turn per accepted message:
clawde.agents.silver={type="project-manager";onDemand=true;channel.type="discord";channel.discord={transport="sidecar";sidecarLifetime="service";botTokenSecretName="silver-discord-token";};};channel.discord.transport = "sidecar" forbids the embedded plugin, so the sidecar owns
the bot token and each accepted message runs one headless harness turn (claude --print
on the claude harness) that exits afterwards; one bot token never has two Discord
clients. sidecarLifetime = "service" keeps the bridge connected whenever the agent is
declared, so onDemand = true or an active-hours gate can keep the pane absent without
dropping the channel. The bridge continues the agent's own channel conversation, kept
separate from any manually started warm session, and dailySessionRotation also starts
a fresh channel conversation whenever the local date crosses.
While the pane is absent, A2A peers cannot reach the agent: pane-backed A2A serves live windows only and reports the agent unavailable rather than waking it. A later increment may wake an on-demand pane for A2A, but Discord delivery never depends on that pane.
The default transport is "auto": claude agents embed through the plugin, codex and
opencode agents bridge through the sidecar, and sidecarLifetime defaults to "agent",
stopping the bridge whenever the wrapper is dormant.
homeManagerModules.default/homeManagerModules.clawde- the module.stewardPayloadPath- path to the steward agent-type payload, for steward declarations.injectAgentIdentity- helper that interpolates fleet identity into an agent personality.checks.<system>.unit-tests- the agent-wrapper, heartbeat, service, steward, and a2a-server python unit suites.
flake.nix plain flake: inputs.nixpkgs only; exports the module, helpers, and checks
flake.lock
README.md
.gitignore
module/
default.nix imports every options/, config/, and adapter module
options/ module interface: option declarations and type contracts
interfaces.nix
agent-type-interfaces.nix
host-wiring-interfaces.nix
options.nix
lib/ pure nix helpers (no config): identity injection, window specs, paths
inject-agent-identity.nix
agent-window-spec.nix
runtime-locations.nix
runtime-paths.nix
lib.nix
config/ the config side of the module: what the options resolve into
activations.nix
agent-assertions.nix
fleet.nix
health.nix
host-identity.nix
instruction-files.nix
launch-config-files.nix
service.nix
workspace-files.nix
agent-types/ per-type defaults (generic, project-manager, steward + its payload)
channel-adapters/ user-facing channels (discord)
peer-adapters/ agent-to-agent transport (a2a server)
instructions/ runtime instruction markdown injected into agents
scripts/ python/bash runtime: agent-wrapper, heartbeat, clawde-service
tests/ pytest suites mirroring the runtime scripts
The options/ -> lib/ -> config/ split is the spine: options/ declares the interface,
lib/ holds pure helpers with no config dependency, and config/ is the only side that reads
options and produces home-manager config, files, and the reconcile service.
The flake provides a dev shell with every toolchain the repo uses (python312 + pytest, nix formatting and linting, shfmt, shellcheck):
nix developFormat the whole tree (nix via nixfmt, python via ruff, shell via shfmt):
nix fmtRun the full gate - evaluates the flake and runs the unit suites on the current system:
nix flake checkRun pytest directly while iterating (faster than a full nix flake check; needs tmux on
PATH and a writable HOME, both provided by nix develop):
python -m pytest -q \
module/scripts/tests/unit \
module/agent-types/steward/payload/tests/unit \
module/peer-adapters/a2a/a2a_server/testsNarrow to one suite or one file the same way, for example:
python -m pytest -q module/scripts/tests/unit/test_wrapper.pyThese are enforced by review, not just tooling:
- Zero comments in any language - no inline comments, docstrings, banners, or TODO notes.
Names carry the meaning: long, descriptive, unabbreviated identifiers. A
shellcheckdirective is a functional pragma, not a comment, and is allowed. - Python 3.12 is the default for scripts. Bash is only for thin glue over shell-native tools.
- Single responsibility per function and per file.
- Test-first when fixing a bug: write the failing test, then fix.
- Never present code that has not been built and tested. For nix, a successful eval/build is the verification.
- Nix is formatted with nixfmt and linted with statix + deadnix; python is formatted and linted with ruff; bash is formatted with shfmt and linted with shellcheck.
See AGENTS.md for the full machine-readable form of these rules.
The checks.<system>.unit-tests derivation is a runCommand that runs pytest over a copy of
the source with tmux on PATH and HOME / TMUX_TMPDIR pointed at fresh temp dirs. It
covers three suites totalling 181 tests:
module/scripts/tests/unit- the agent runtime. The agent-wrapper (launch, stuck-indicator detection, pane responsiveness, session watchdog, restart scheduling, redeploy signals, resume nudge), the heartbeat (cron scheduling, tmux driver, change-gate edge triggering), the clawde-service reconcile (window-name and agent-identity reconciliation), clawde-redeploy, and the Discord reply stop hook.module/agent-types/steward/payload/tests/unit- the steward agent payload. Repository, submodule, and CI status probes, health summary, the heartbeat probe, and the activate/status/msg entry points.module/peer-adapters/a2a/a2a_server/tests- the A2A peer server. Agent card, task store, active-task coordinator watchdog, and the tmux backend (observe, send-input, meaningful-line extraction), plus the server and subprocess-backend integration tests.
Tests mock subprocess interactions; the ones that exercise tmux need a real tmux binary and a
writable HOME, which nix develop and the check derivation both supply.
The suites run across all supported systems via forAllSystems:
x86_64-linux, aarch64-linux, x86_64-darwin, aarch64-darwin.
CI runs the same gate a developer runs locally, so a green local nix flake check predicts a
green pipeline:
nix flake check- evaluates the flake and runschecks.<system>.unit-tests(the 181-test suite) on the CI runner's system.nix fmt -- --check(or the equivalent per-tool checks) - fails the build on any file that is not formatted by nixfmt / ruff / shfmt.- statix + deadnix over the nix tree, ruff over python, shellcheck over bash - lint gates that match the conventions above.
CI is the only authority on green; the repo is kept synced and pushed only after the gate passes.