Teaching an AI agent to speak Technitium
Building a DNS skill for our network agent — and the two-layer permission model that hid our zones in plain sight.
We run a few AI agents in the homelab — one for systems, one for security, one for the network. They aren’t chatbots bolted onto a wiki. Each is a thin shell around a set of skills, and a skill is only worth building if it has something real to talk to. That distinction shaped this week’s work: giving the network agent control over DNS.
A skill is tools, not a textbook
The first decision was what not to build. It’s tempting to make a “DNS skill” stuffed with explanations of zones, records, TTLs and delegation. We didn’t. A language model already knows what a CNAME is — wrapping that in tooling just spends tokens teaching it things it isn’t getting wrong.
So we split by target, not by topic: a skill for host resolver config, and a separate one for the thing that actually serves our DNS — Technitium. The skill’s job isn’t to explain DNS. It’s to know what Technitium does differently.
The quirk worth encoding: 200 OK on failure
And Technitium has quirks worth encoding. The big one: its HTTP API returns 200 OK even when the request failed. Success or failure lives in a status field inside the body, not the HTTP code:
{ "status": "error", "errorMessage": "Access was denied." }
That arrives with a perfectly cheerful HTTP/1.1 200. Trust the status line and you’ll report a write as “done” while the server quietly rejected it. So the client checks the body every time — and we wrote that rule into the skill’s reference, so the agent applies general DNS knowledge the Technitium way.
Gated writes and a second token
We were careful with changes. Reads use a read-only token; writes use a separate read-write token, so a read path can never accidentally carry write privilege. Every write tool is gated: call it once and it returns a preview — “add A record nas.home.arpa → 10.100.0.20” — and does nothing. Only after explicit approval does it execute. No silent edits to live DNS.
The hiccup: a zone that existed and didn’t
Then came the snag that cost us an hour. A round-trip test created a zone, added a record… and reading it back failed with “Access was denied.” Odd — the read-only user, we were told, had view rights on everything. Stranger still: listing zones with the RO token returned zero zones, while the RW token could see the one we’d just made. A zone that existed and didn’t, depending on who asked.
The culprit was Technitium’s two-layer permission model. There’s a section permission (“can this user view the Zones page?”) and a separate per-zone permission on each individual zone. A freshly created zone grants access only to its creator and the Administrators group. Our RO and RW tokens belong to two different users — so a zone created by the RW user was simply invisible to the RO user. “View on everything” was true at the section level and irrelevant at the zone level.
The fix took two minutes once we understood it: add the read-only group to the zone’s permissions. The RO token immediately listed the zone and read every record in it.
The takeaway
An old lesson in new clothes: section-level and per-object permissions are not the same thing, and an empty list is not always an empty system — sometimes it’s just a list you’re not allowed to see. Worth remembering, whether the thing doing the reading is a person or an agent.