Henk — Homelab Agent

Running since July 2026

View on GitHub
PythonClaude Agent SDKDockerSignalntfyTailscalepytest

A personal homelab agent (Henk, the Dutch neighbor who knows where everything is) that turns infrastructure alerts into Signal conversations. Built on the Claude Agent SDK around a default-deny security posture, with an event pipeline whose delivery guarantees were proven on real hardware, not just in tests.

233 tests, SDK mocked out
0 mutating tools shipped
1 owner; everyone else is dropped
4 egress ports, zero inbound
exactly once event replay across restarts

What I Built

Channel and agent core. Henk lives in Signal, on a dedicated number behind a containerized signal-cli bridge that publishes no ports. The adapter layer is channel-neutral (only one module knows Signal exists), and everything inbound passes an owner-only allowlist that refuses to even construct with an empty owner id, so there’s no fail-open path. The agent core runs typed turns through a serial queue, one session per conversation, wrapping the Claude Agent SDK.

Event pipeline. Alerts arrive over an ntfy subscription, get a per-source identity, and pass a cadence policy (debounce, per-identity cooldown, recurrence tracking, and a daily cap on unprompted messages) before becoming a triage turn. Every outcome lands in an append-only JSONL audit log with a versioned schema. Triage output goes to Signal plus a published handoff other tooling can pick up.

Security posture. Default-deny everywhere: a closed toolset enforced at a PreToolUse choke point, read-only tools only, an inline approval gate for any future mutation, one ntfy token scoped per-topic server-side, and its own Tailscale identity with egress to just the four ports its tools use. No SSH keys, no broad API credentials, nothing it doesn’t need.

Henk's trust boundary and event flow Untrusted sensor alerts and owner Signal messages both cross a trust boundary into Henk, where events pass an intake cursor and cadence gate, owner messages pass an allowlist, and everything converges on a serial agent core whose every tool call must pass a default-deny PreToolUse choke point before reaching read-only tools, the audit log, or Signal. UNTRUSTED INPUT OWNER Gatus · Prometheus Grafana alert rules Owner on Signal dedicated number ntfy henk-events deny-all topic signal-cli bridge no published ports TRUST BOUNDARY Henk · rp5 · tailnet tag:henk · zero inbound event intake durable replay cursor owner allowlist others dropped + logged cadence gate debounce · cooldown · cap serial agent core Claude Agent SDK PreToolUse choke point default-deny · closed toolset read-only tools health · todos audit log append-only JSONL Signal + handoff owner only
Two ways in, one way through: every tool call passes the default-deny choke point, every outcome lands in the audit log.

Key Decisions

Structure over prompting for injection defense. Untrusted sensor payloads get bracketed and framed as data, but I treat that framing as defense-in-depth, not the defense. While testing I watched the SDK’s permission callback get skipped for auto-approved built-in tools (they executed without it ever being consulted), so I moved enforcement to a PreToolUse hook that settings-file allow rules can’t bypass. The guarantee is structural: an out-of-registry tool call dies at the choke point no matter what the prompt says.

The checkpoint advances only on durable outcomes. The intake cursor is a one-value file that only moves after an event’s audit record is confirmed written, in delivery order. It isn’t strictly monotonic though: the recovery fallback (below) can deliberately rewind it, trading a bounded over-replay for never dropping an event silently. And the first failed flush latches the cursor in place process-wide and notifies me over Signal. Replay over silent loss, always.

Cadence state rehydrates from the audit log. I didn’t want a second store that could drift out of sync, so cooldowns, cap windows, and recurrence references are rebuilt from audit records at startup. This one is a regression fix for a bug I actually watched happen: three redeploys in one day kept wiping in-memory state, and capped alerts walked right through.

Probe the contract you depend on. ntfy’s since parameter is undocumented, so I measured it against the live server: resume is exclusive, an evicted id silently returns the whole cache, an unparseable one returns 400. That probing exposed a wedge my own durability work had introduced, where a server-rejected cursor would be retried forever and kill intake silently. The fix is a replay-all fallback scoped to 400 responses only, because a false positive costs a bounded over-replay while a false negative costs permanent silent death.

Redaction can leak through the prose around it. Publishing the repo took two audit passes, and the second caught what the first missed: a value was properly redacted, but the text around it explained that a value was being withheld, across 23 commits and 2 commit messages. That class of leak lives in framing, so it passes a clean grep. I rewrote the history with git-filter-repo and added a pre-commit hygiene gate (gitleaks plus repo-specific patterns, added-lines only, hard-fail if the scanner is missing) so the lesson is enforced rather than remembered.

Outcome

Everything above was verified live on the Raspberry Pi 5 it runs on. A hostile payload attempting instruction override, out-of-registry tool calls, and env-var exfiltration produced a call pattern identical to a benign event, and Henk flagged the injection as a possible compromise of the alert source, lowering its confidence because the input was anomalous. An event published while the container was down was triaged exactly once after restart. Cap state survived a full rebuild redeploy. 233 tests cover the pipeline, permissions, and durability paths, with the SDK mocked out.

The honest caveat, straight from the repo’s own records: after the verification window, production went nine days with zero real events, so the cadence defaults remain untuned. Henk is a correctly-built, thoroughly-verified agent, the incident volume just hasn’t arrived yet, and the project documents that rather than claiming otherwise.