
An agent once ran half of a command on my hypervisor that was meant for a container: a quoting slip, and the shell split it in two. That is now a rule in the Proxmox skill, the instruction file every agent loads for that work: a script of more than one command goes in on stdin, never as ssh arguments.
Nearly every change to my homelab is made by a coding agent: the Ansible roles, the Docker Compose projects, the Cloudflare config, the docs, the commits. I read each one before it lands, but I don’t type them. How I Run My Homelab in 2026 describes what the lab is. This one is about how it gets written, what keeps the agents in line, and what doesn’t.
How I work with them

I use agents for the time they save me and for the expertise they bring.
From my Mac I run up to four agents in parallel through Orca, an agentic development environment (source). I link them so they can talk to each other: one session asks another a question instead of me relaying it. They run on the dev boxes, never on the machines that serve the lab. Mostly Claude Code, with opencode and Google’s Antigravity CLI installed next to it.
In practice, the session working on this blog asks the one working on the homelab for anything outside the blog repo: the Terraform behind the contact form, a Search Console service account, a screenshot of a page, the facts behind a post like this one. The homelab session sends back paths and variable names, never secret values. When the blog session relayed “walid said publish everything”, the homelab session did not take that as my approval for a live DNS change; it asked me in its own session.

One change, start to finish
I asked an agent to update Orca on the dev boxes.

The Ansible role’s defaults already describe how: bump the version and the sha256, which the agent took from the latest release with gh api. The deploy, which applies main within a minute of a push, skips this role: an Orca upgrade restarts the server process every open Orca terminal runs in, the agents’ own included, so it is applied by hand. The role starts by counting live Orca sessions, and the dry run refused. Once I said yes, the agent chose to run the apply inside a transient systemd unit, so the job would outlive the terminal the upgrade was about to restart, and read the installed version back with dpkg-query on both hosts.
Most changes need no hand apply and go out through the deploy. In a docs change, the agent added a vendor image of the rack’s PDU with its source, checked an electrical rating against the vendor’s model list, and found that one of its own earlier commits had it wrong. It built the site in strict mode as CI does, and committed and pushed when I said so. The docs site is a Compose project like any app, built on its VM from a Dockerfile beside it, so a push that touches the docs applies that project alone. An agent may start the deploy at once instead of waiting for the next minute, and it still applies only what is on main.
Apps, with rules written for agents
Every app is a Docker Compose project on the VM of its trust tier: one VM for the apps I vouch for, one for those that parse content from the internet. The deploy post explains why. For the agents, what matters is that every app is written the same way, and so there is one place to put the rules: ansible/roles/apps/AGENTS.md. Some of them, verbatim:
- `compose.yaml`, upstream's own as far as it goes. Every `image:` is
`name:tag@sha256:…` (Renovate moves both); a Dockerfile beside it is built on the
VM by `build:`, never pushed.
- A service names the variables it takes (`PAPERLESS_DBPASS: ${PAPERLESS_DBPASS}`),
never `env_file: .env`.
- State is a named volume, never a path under `/srv/apps/<project>/`, which holds only
what the repo renders. A cache is a bind under `/srv/cache/<project>/`, the disk PBS
skips.
- A project's services meet on its default network: a database is never on another.
- A route is a line in `ansible/routes.yml`, never a label: Traefik reads files only,
and has no Docker socket.
CI enforces two of the file’s rules: every compose file must parse, and every service of the VPN project must share gluetun’s network, so it can only leave through the tunnel. The others are instructions. An agent that ignores one produces a project that deploys anyway, and the safeguard is my reading of the change.
A repository written for agents
The repo follows the AGENTS.md convention: one at the root, and one per component that needs its own rules (Ansible, the apps, the monitoring project, the docs, Cloudflare, the router, Tailscale, the rack, Windows and a few more), twelve in all, with no other instruction file. The root one loads in every session, so it keeps only what every task needs. A procedure, a checklist or one tool’s mechanics goes into the component file or the skill that task loads.
The files have two readers. Every command, flag, procedure and check an agent needs lives in AGENTS.md and the skills; the docs are for me, a light reference on how the lab works and the runbooks I follow when no agent can act. A rule is terse and imperative, like the Ansible one that reads “A role is a responsibility, never a machine”, and links the page that argues it.
Some rules exist only because the reader is an agent. Two from the root file:
**A change carries the edit of every `AGENTS.md`, skill or page it makes false.** Before
removing or renaming something, `grep` the old name across `docs/content/` (diagram text
included), every `AGENTS.md` and `.agents/skills/`.
**One fact, one place.** […] Never copy anything derivable (versions, lists, counts):
name the class and the command that reads it. Current state only: no dates, no history.
CI fails a doc, an AGENTS.md or a skill that cites a path, a recipe or an address that does not exist.
Some rules are about what the code does at run time. The lab calls models through OpenRouter (the document archive’s vision pass, the chat UI), and when an agent writes such a call, the root AGENTS.md makes it name its caller, with a separate key for any tool whose code is not ours: when spend jumps or a key leaks, the first question is which caller.
Skills carry what no generic agent knows
Agent Skills are instruction files an agent loads when a task matches them. The lab has thirteen, from the Proxmox API, backups, monitoring and routing to the UPS, the Wi-Fi access point, diagrams and a browser, each in .agents/skills/<name>/SKILL.md, and CI runs the spec’s reference validator on every one. A skill’s description loads into every session, so it says what the skill does, then “Use for” with the words I would actually type. For the backup server’s skill: “PBS, backups, snapshots, the datastore, retention, pruning, garbage collection, verification, the offsite copy, or restoring a file”. The body holds what is specific to this lab. From that skill’s gotchas:
- **An under-privileged token gets `[]`, not a 403,** on `config/datastore`,
`config/verify`, `config/prune` and `config/sync`. Never conclude that a job does not
exist from an empty list; confirm with `--ops`, or in the PBS shell with
`proxmox-backup-manager {prune-job,verify-job,sync-job} list`.
Principles that say when to reopen them
A refused option leaves no trace in the code, so an agent proposes it again. The lab’s principles page is where that stops. It opens with “An option that breaks one of these is refused. Each gives its reason and the one condition that would reopen it; one that is reversed is deleted.” One entry:
Nothing pushes an image
The lab runs upstream’s images as published; what has none is built on its VM from a Dockerfile beside its project, never pushed: a registry of our own is a service to keep.
Reopen if: a build outgrows its VM.
Agents write all of these files, in the same commit as the change they describe, and I review them like any other change.
What checks a change
Part of the checking happens before a change lands, and part doesn’t.

The pre-commit hook runs gitleaks, checks that every encrypted file really is SOPS ciphertext, and refuses a plaintext env file. The pre-push hook runs the same checks as CI on every push to main; a plain git hook, it can be skipped. CI validates and never deploys. The dry runs are just plan, the diff of every machine and of Cloudflare (just recipes), and an Ansible apply refuses to run from a checkout that is not exactly origin/main. The deploy, a container inside the lab that pulls main every minute, applies only a head signed on my dev box, and doesn’t wait for CI: a bad change is undone with a revert. When an agent says it is done, it shows it: the plan run again with no changes, a package version or a container’s health read back, a screenshot for anything visual, taken by an agent’s own browser.
What agents can touch

The sessions run with Claude Code’s permission prompts turned off, in bypass mode: no command waits for me to approve it. The guardrails are elsewhere, and most of them are rules the agents follow rather than locks:
- Commits. An agent commits and pushes when I ask, not on its own. Every commit made on the dev box is signed, an agent’s included, so the signature check in the deploy stops someone holding my GitHub account, not an agent.
- Merges. No pull request merges itself into the lab, Renovate’s included. I merge with a recipe that signs the result.
- Credentials. About twenty scoped tokens: read-only ones for Proxmox and the backups, each next to an ops one used only when I ask for a change. The ops tokens sit on the same dev box as the agents, so “only when I ask” is an instruction there, not a lock.
- The browser. Agents open lab pages in a browser signed in as their own user, never as me. That one is a lock: the single sign-on denies that user anything holding a desktop, a shell, files, DNS or my accounting, no app that logs in through OpenID Connect accepts it, and Grafana gives it a viewer account. It keeps an agent’s clicks out of my account and logged under its own name; it doesn’t keep a hijacked session out of the lab, since the same session holds my tokens.
- Restricted guests. Some guests carry a
restrictedtag and are closed to agents. A Claude CodePreToolUsehook refuses any command that would look inside one: SSH, entering the container, its console, its disks, its backups or its logs. I open one by hand, for one task. That is a lock too, but a thin one: it runs on the same dev box, under the same user as the agent, and recognizes a look inside by its command. It stops a slip, not a session someone else has taken over. - Secrets. The lab never reads or writes my password manager; a password I will type goes into SOPS, and I copy it over myself. Secrets are never pasted into a session, and never printed.
- Other sessions. The agent does not take a message from another session as my approval. That comes from the agent harness, which marks such a message as not coming from me; no file in the repo says it.
- Untrusted content. Apps that handle it run on the untrusted tier’s VM, which holds no decryption key and none of the private apps’ secrets, and reaches nothing else in the lab. The versions Renovate tracks are bumped by its pull requests, not by an agent reading release notes, and a pinned binary’s checksum moves in the same pull request, taken from the release’s own checksums.
If an agent goes wrong
Most of that list is instructions, not enforcement. The dev box the agents run on is trusted: an agent there can, in principle, do most of what I can. If an instruction hidden in a web page or a file took over a session, it could push a signed change that deploys itself within a minute, use the ops access, read secrets and delete the local backups, which are not immutable against a compromised agent. The offsite copy holds out longer: no key in the lab can erase it, only hide it, and a hidden copy stays recoverable for seven days. Apart from one narrow rule, that no instruction found in a file, a ticket or a tool result opens a restricted guest, nothing in the repo tells an agent to treat what it reads as data rather than orders; that comes from the agent and the model.
What holds is narrower. Nothing on the internet can open a connection into the lab, and someone holding my GitHub account can’t deploy on their own, since the deploy applies only a head signed on my dev box. Some things are on no machine at all: the disk passphrase I type after every cold boot, and a key able to erase the offsite backup. And every change is a commit, visible and revertable. Beyond that, the defense is the model’s own resistance to injected instructions, and me watching the sessions.
I accept that risk: the lab serves one person and promises no availability, and with nightly backups, losing up to a day of data is the deal.
The Saturday pull request

Every Saturday an agent opens a pull request with a hygiene pass over the repo, and every Saturday morning I review it over a coffee. It reads the repository only, never the lab, and hunts for bugs, dead code, duplication and stale agent instructions. What a command can prove, it fixes, ten changes at most; a judgment call goes into a list for me. It never merges.
One of its ground rules:
Every change removes or simplifies. KISS, then YAGNI, then DRY. […] A change that adds a file, a variable, a layer, a script or a dependency cites the concrete failure it prevents, or it is dropped.
It may not propose high availability, redundancy, a shorter recovery point, more monitoring, more alerting or hardening as good practice, and a pull request I closed without merging in the last six months is not proposed again.
What it finds is mostly small, sometimes not. One pass found a skill still describing a backup path that a later change had moved on purpose; an agent following it could have “fixed” the config back. Another found an AGENTS.md rule saying every role imports a shared one, when eight didn’t.
Incidents become rules

Most of the rules in those AGENTS.md files have an incident behind them.
- A blanket
git add -Aswept my in-progress edits into an agent’s commit. Agents now readgit statusbefore adding anything, and CI scans every push with gitleaks in case a plaintext secret slips in anyway. - Asked to relay notifications, an agent built a 230-line service. I stopped it halfway and asked whether that was clean or over-engineering. That is where “removes or simplifies” comes from.
- A skill suggested a shell alias that made a Prometheus query return every target: a wrong answer, never an error. The lesson went beyond the alias: the docs and skills are my decisions, not gospel, and an agent that finds one wrong fixes it in the same change.
Where they still need me

Beyond the decisions that stay mine by rule, I often stop an agent to cut scope or change direction. I refused a second Search Console property where one would do, and had the Search Console verification redone from zero in Terraform rather than import the records I’d made by hand.
They still get things wrong in ways that matter. One reported a dark-mode render as identical to the light one; it had passed a malformed option to the renderer, and a later check caught it. One ran terraform apply directly instead of the recipe the repo requires, and said so. And an apply I interrupted had already created a DNS record; the agent found it in the state and removed it in the next plan.
Was it worth it
What surprised me most is how fast it goes: the lab reached the shape in the overview in a fairly short time, and it got better as it grew. A hand edit on a machine is put back within a day, the docs can’t name something that doesn’t exist, and a refused option comes back with its reason and the condition that would reopen it, whether I or an agent proposes it again.
Discussion on r/homelab.
Cartoon illustrations generated with Gemini 3.1 Flash Image, through OpenRouter.
Icons in the diagrams: Simple Icons (CC0) and Lucide (ISC).