Skip to content
Walid Karray
Go back

My Homelab Is Written by Coding Agents

Updated:

Two-ink cartoon of me with a coffee next to a small home server rack, smiling, while small robots work on it: one plugs in a cable, one carries a box, one climbs a ladder, one hands me a sheet to sign

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

Hand-drawn sketch of the workflow: from my Mac, Orca drives up to four agents on a dev box that talk to each other; they commit to the repo's main branch on my word, I read every change, and the lab pulls main

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.

The blog session asks the homelab session for infrastructure and gets back paths and variable names, never secrets; a relayed "walid said publish everything" does not reach the live DNS change, which goes ahead only after the homelab session asks me directly

One change, start to finish

I asked an agent to update Orca on the dev boxes.

One change from request to proof: I ask for an Orca update, the agent bumps two lines, the dry run refuses because terminals are open, the agent asks whether to force it, I say yes, it pushes and applies, then checks the new version is running

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 path of a change: the agent's change goes through a dry run, my reading, the pre-commit hook with gitleaks and the SOPS check, and the pre-push validate before it reaches main; the converge pulls it, applies signed heads only, and proof is read back from the live system; CI runs beside main, reports and deploys nothing

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

Hand-drawn sketch of the fence: in a session I watch, agents may touch the repo, guest definitions, read-only tokens, a browser signed in as their own user and Grafana as a viewer; commits and pushes, merging pull requests, the ops tokens and a restricted guest for one task happen only on my word; the disk passphrase and a key that erases the offsite copy are mine alone, on no machine

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:

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

Two-ink cartoon of my Saturday ritual: at a café table, coffee at hand, I review the hygiene pull request on a laptop while three small robots wait beside it, one holding a sign that asks "merge?"

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

Hand-drawn loop: an agent changes something, something breaks, a rule goes into AGENTS.md, and the next agent reads it

Most of the rules in those AGENTS.md files have an incident behind them.

Where they still need me

Two-ink cartoon of a proud little robot beside an absurdly over-built machine of gears, pipes and cables that only swallows an envelope, while I raise a hand to say stop, coffee in the other, and a second robot scratches its head

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).


Share this post:

Related posts

Follow new posts by RSS

Previous Post
How I Run My Homelab in 2026: Proxmox, Docker Compose and Tailscale
Next Post
Proxmox and Synology UPS Shutdown With NUT: Don't Trust the Low-Battery Flag