Contributing to homebutler
September 18, 2026 · View on GitHub
Thanks for your interest in contributing!
What homebutler is
homebutler operates a homelab server through one structured surface that both people and AI agents use. It answers "what changed?" rather than "what is the state right now?"
That sentence settles most scope questions. If you are unsure whether an idea fits, open an issue before writing code — we would rather talk it through early than turn down finished work.
What we accept
App catalogue additions
Adding an app to homebutler install is the easiest place to start, and the bar
is mechanical:
- Official or first-party image
- Pinned tag, not
:latest - Default ports that do not collide with the existing catalogue
- Volume and data paths following the existing convention
- A smoke test that passes: install, start, health check
- README app list updated
We do not debate whether an app belongs in a homelab. If the checklist passes, it goes in.
Everything else
Four questions, in order. A "no" is not a verdict on the idea — it tells you what the PR is still missing.
- Which target? homebutler covers
docker,systemd,pm2,ports,network, andsystem. A new target is a larger commitment: open an issue first, and expect to answer questions 2 through 4 for the whole target. - Does it emit JSON? Human-readable output is not enough. Anything a person
can ask for, an agent should be able to parse — including on the
--jsonpath. - Which question does it answer? The README lists the ones homebutler exists for: what is running, which container owns this port, why did this restart at 3 AM, is the backup restorable, what is reachable from outside. A new question needs a case for why operators ask it.
- If it detects a problem, can something act on it? Either remediation
arrives with the detection, or an issue is open committing to it. A read-only
first phase is fine when a second phase is named. Reporting state —
docker top,docker inspect— is not detection and this question does not apply.
Priorities
Depth before breadth. Covering the existing targets thoroughly comes before adding new ones.
Working toward 1.0, which freezes the MCP tool surface and the JSON schema:
- Compare identities rather than counts, so a container swap is not read as no change (#58)
- Track processes and network rather than only observing them (#59)
Those two are the question homebutler exists to answer, which is why they come
before adding more things to ask it about. They also have to land before 1.0
rather than after: they change the shape of what report returns, and 1.0
freezes that shape.
New targets generally wait until after 1.0, so open an issue before starting one. Work already discussed and agreed in an issue keeps the terms it was given. Proxmox is past its original scope — #104, #105 and #107 are in review, and #106 is open and unclaimed.
Before submitting a PR
Please run these checks locally before pushing:
# Format code
gofmt -w .
# Run linter
golangci-lint run
# Run tests
go test ./...
# Build
go build ./...
All four must pass. CI will reject PRs that fail any of these.
If you touched anything under web/, there are two more. The component tests
are quick:
npm --prefix web test
The end-to-end suite drives a browser against a real homebutler serve --demo,
so it needs Chromium once:
npx --prefix web playwright install chromium
npm --prefix web run e2e
It starts both servers itself and covers the states that are otherwise only reached by accident — a dashboard asking for its token, an endpoint returning 500, an incident opened to read the logs captured before the container died. CI runs it in a job of its own, so a frontend change that builds and still breaks in a browser fails there rather than on someone's dashboard.
If the change is visible to someone using homebutler — new output, a new flag, a
different default, a message that reads differently — add an entry to
CHANGELOG.md under ## [Unreleased] in the same PR. Something that used to
work and now does not goes under ⚠️ Behavior changes, which is the section
people read before upgrading. An internal change with no user-visible effect
does not need one. If you are unsure, write the line and let the review decide.
Linting with what CI lints with
CI pins golangci-lint rather than taking the newest, because a linter that upgrades itself breaks the build on a day nobody touched the repository. Match it locally or you will pass here and fail there:
golangci-lint --version # compare with .github/workflows/ci.yml
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2
Dependabot raises the actions and the dependencies, not those pinned tool versions — they are inputs to an action rather than a version of one. Raising them is a pull request of its own, which is the point: CI runs the new linter against the whole tree before it can fail a release.
Changing something other people build on
Tool names, JSON fields, risk classes, doctor's exit codes, documented config
keys and the HTTP routes are frozen at 1.0, and
docs/compatibility.md says exactly which of them and
what is still allowed to change. go test ./internal/contract fails when any
of it moves, with the diff and the command to regenerate the golden file. If
the change is deliberate, regenerate, commit the golden file alongside it, and
write the ⚠️ Behavior changes line.
Cutting a release
The version in skills/SKILL.md is pinned, because a skill that tells an agent
to install whatever is newest gives it something it cannot account for
afterwards. So the release PR that moves ## [Unreleased] to a version number
also updates that pin — a test compares the two and fails the build if they
drift apart, which is the reminder.
Code style
- Follow standard Go conventions
- Run
gofmton all.gofiles - No unused variables or imports
- Commit messages follow Conventional Commits (e.g.
feat:,fix:,docs:)
PR guidelines
- One feature/fix per PR
- Include tests for new functionality
- Update README if adding user-facing features
- Keep PRs small and focused