nfltr on nfltr, part 2
nfltr connects machines through a relay they dial out to, and nfltr orch runs Claude Code agents on your own machines, driven by a hub that is your own Claude Code session. Running the hosted service at nfltr.xyz takes the same things every small operator needs: a way to see whether the service works, a way onto the production machine, a Linux machine to test on, and a release process that cannot push to production without the owner. If nfltr cannot carry those for us, it is hard to argue it should carry them for anyone.
What we did and found: in nfltr.xyz now runs on nfltr the public sites moved onto an ordinary nfltr agent. Since then four more parts have moved onto nfltr itself: a status page built from the hourly canary, operator access to the production VM, a Linux machine for agents and the canary, and the release process, which a hub now runs with every production step approved by the owner's fingerprint. The first hub-run release, v1.0.694, went live but found three bugs on the way; the second, v1.0.695, ran every step ok with no retry. This post covers each part, the bugs, the evidence and the limits.
status.nfltr.xyz, built from the canary
A canary runs every hour on the operator's Mac. It starts a fresh hub on the hosted relay, spawns an agent on each canary node (one of them pushes a branch), runs a monitor, and checks that every completion arrives exactly once, that the pushed commit is on the origin and that nothing is left behind. The agents are a scripted stand-in for Claude, so a run costs no model calls. Each run appends one JSON line to a log that stays on that Mac.
After each run, the canary writes a small typed summary beside the log: the newest run and its result, the newest relay version, the success rate over 24 hours and 7 days, the time from spawn to first progress and to completion (p50 and p95), and up to five recent failures as reason codes. A failure reason the page does not know becomes other. A run's details, its hub, nodes, machine names and agent ids never go into the summary, and the site build refuses a summary with unknown fields or reasons. The build renders the page to static HTML, and the same site agent that serves the other sites serves it at status.nfltr.xyz.
A static page can go stale without anyone noticing, so it says when it was made and marks itself stale when its newest run is more than two hours old. It does that twice: when it is built, and with a six-line script in your browser, so a page that stopped being republished turns stale on its own instead of showing an old pass.
We rejected having the relay store or serve canary results (the relay keeps no view of runs), serving the page from the Mac (a laptop sleeps, and the page would vanish exactly when it matters), and a hosted status service (the data would leave our machines). The limit is plain on the page itself: one machine runs the canary, so a gap means that machine was offline, not that the relay was down.
Operator access to the production VM
Until now the only way onto the relay's VM was the cloud provider's identity-aware SSH tunnel. Now a second agent runs on the VM, next to the relay: an nfltr up agent with two TCP services and nothing else.
agent: access
services:
ssh: { tcp: 22, forward: "127.0.0.1:22" } # the VM's own sshd
metrics: { tcp: <port>, forward: "127.0.0.1:<port>" } # the relay's metrics listener
The shell goes through the VM's own OpenSSH server, with the same login and key as before. The agent is only a pipe; sshd still authenticates every session. From the laptop, ssh uses nfltr ssh-proxy as its proxy command, with the host key pinned, so the relay sees only SSH ciphertext. The metrics service is how the relay's /metrics reaches Prometheus and Grafana on the operator's laptop, with no port open to the internet.
Only keys of the owner account reach the agent. It has no HTTP service, so it has no share URL, and the agent dials only the two loopback ports in its file. Its systemd unit runs as a dynamic user with a read-only filesystem, no capabilities, the relay's secrets and the site's files hidden, memory and CPU caps, and an OOM score that makes the kernel kill it before the relay. Its key comes from the cloud's secret store into a root-only file read by systemd before it drops privileges; it is never on a command line or in a log.
One relay behaviour shaped the setup. The relay resolves the agent name in a tcp-connect or ssh-proxy request inside the calling key's own namespace. So a laptop key cannot name an agent that runs under another key of the same account, although the ownership rule would allow it. The VM's key is therefore minted on the dashboard under the laptop key's name. A follow-up will let any key of the owning account reach the account's agents.
Update: that follow-up landed the same day, in relay-server v0.4.0 (October 10, 2026): a connect request may name the full agent ID of any agent its account owns, while another account's key stays in its own namespace. Since v2.1.6 (October 16, 2026) both services are also end-to-end encrypted: the agent ends TLS with its own certificate, the laptop pins it, and the relay carries only ciphertext, for the metrics too.
The cloud provider's SSH stays as break-glass. The access agent goes down with the relay by construction, so every deploy, rollback and recovery script still uses the provider's SSH, and none of them uses the access agent. A local end-to-end test runs the shipped config against a relay with real accounts: the owner gets a shell and metrics, a key sshd does not know is refused, another account's key and an unknown key get 404 and 401, and sshd sees only the owner's connections. The production verify step checks the same from both sides.
Evidence: only the owner's keys reach the access agent
- Setup
- A local end-to-end test: a private relay with real accounts and the shipped access config, a throwaway OpenSSH sshd standing in for the VM's; October 9, 2026. In production, the deploy's verify step from both sides.
- Numbers
- The owner gets a shell and metrics; a key sshd does not know is refused by sshd; another account's key gets 404 and an unknown key 401; sshd sees only the owner's connections; no undeclared target is reachable.
- Reproduce
- (a) With the released binaries, for your own server:
nfltr up -c nfltr.yamlthere with the file above, andssh -o ProxyCommand='nfltr ssh-proxy --peer %h --port %p' <user>@<agent>from your laptop. (b) Our check, in the closed nfltr repository:make smoke-test-prod-access($0, host-local; needs an OpenSSH sshd) andmake test-standalone-access(static checks of the unit, the key handling and the loopback-only targets). - Limits
- The test covers the access rules, not the sandbox under real attack; the unit's hardening is checked statically. It cannot help when the relay itself is down.
We rejected the embedded nfltr shell server with sudo (it needs setuid inside the sandbox of a process the relay can reach, and a second SSH implementation), a WireGuard tunnel for one port, and a share link, which is a link for other people.
A Linux node on the Mac
Everything above runs from a Mac, and nfltr ships for Linux too. So the Mac's Docker VM now runs two long-lived Debian 12 containers, each an nfltr node joined to nfltr.xyz on the operator's account:
- A node for real Claude Code agents, available to any hub of the account: two agents at a time, every tool, the Go toolchain the module names, git, make and sudo, and the network privileges kernel WireGuard and TUN tests need. The operator's repository is mounted read only; an agent clones from the node's mirror, builds and tests, and returns its result or a patch, because it cannot push there. A hub reaches it with
constraints.machine. - A canary node with the scripted stand-in for Claude. While its container runs, every hourly canary run crosses macOS and Linux through the hosted relay. When the container is down, the run goes on with the Mac nodes and records a typed skip reason (
docker_unavailableorlinux_node_down); a skip is not a failure and never reaches the status page.
The image installs the released nfltr with the public install.sh, which verifies its signature; it is never built from source. The API key and the Claude token are written to 0600 files and mounted as secrets, never into the image, the compose file or a command line. A guard test checks that offline and, while the containers run, against the image layers, docker inspect, the container logs and every process's arguments. Idle, each node uses under 1 % CPU and about 10 MB.
Evidence: an idle Linux node costs under 1 % CPU and about 10 MB
- Setup
- Two Debian 12 containers in the Docker VM of the operator's Mac, the released nfltr, joined to nfltr.xyz, no agent running; the canary node capped at 0.5 CPU and 512 MB; October 10, 2026.
- Numbers
- Under 1 % CPU and about 10 MB of memory per node, averaged over five samples.
- Reproduce
- (a) With the released binaries, on any Linux machine:
nfltr node join --max-agents 2, thenps -o %cpu,rss -p <pid>while it is idle. (b) Our check, in the closed nfltr repository:make linux-node-up(needs Docker and a saved API key; it joins your account) andmake linux-node-status, which fails over 1 % idle CPU;make test-linux-noderuns the offline checks, including the secrets guard. - Limits
- Idle cost only; an agent running Go builds uses what the build uses. One Mac, one Docker VM.
Releases run by a hub
A release is now a hub goal, "release vX.Y.Z". The hub, the operator's own Claude Code session with the hub tools added, runs it on a build node on the operator's Mac. nfltr provides the pieces; the plan lives in a short procedure the hub is given (2.9 KiB of operator-owned text), and every decision in a run, whether to go on, retry, ask the owner or roll back, is the hub's. Nothing in Go encodes the plan. Hubs that run work like this are described in How distributed Claude Code is built with nfltr.
These two releases ran in the operator's Claude Code session with the hub MCP added (nfltr mcp --toolset hub --hub-id release and the procedure). At the time, make release-hub in our closed nfltr repository started the same hub through the nfltr orch launcher (nfltr orch --hub-id release ...). That launcher was removed in v2.1.20 (October 22, 2026); make release-hub now starts Claude Code with the hub MCP and the procedure as an appended system prompt, interactive or claude -p "release vX.Y.Z". Reattaching is reopening that session with the same hub id.
- One step per agent turn. A step script runs one step and prints one
RELEASE_STEPJSON line: the step, the version, ok, the exit code and its facts. The hub judges a step only by that line. The steps are preflight, gates, publish, relay, site, verify, canary, status and summary, plus rollback and site rollback. - Checks, not decisions. The relay step refuses to run until the version's downloads are published, because the relay serves
install.sh, which installs the newest published version. Publishing a version again re-uploads the same bytes or refuses different ones; deploying the live version again keeps the rollback target; a rollback acts once; a second production step for the same version waits while one runs. - The node's tool policy. Production steps run only through the step script and only with the owner's grant for that exact step. Direct deploy commands, the cloud CLI, shells, interpreters, file changes, git writes and credential reads are denied in every turn, approved or not.
A grant is an Ed25519 signature over the node, the exact step prompt and an expiry, spent on its first use. The key stays in the operator's macOS login keychain, and a small Swift helper signs with it only after Touch ID; there is no password fallback. The dialog shows the step, the node, the expiry and the prompt's SHA-256, the same digest the hub's approval request shows. The owner can sign each production step when the hub asks, or sign them all before the run.
This works without a paid Apple developer entitlement, with a compromise. Having the keychain itself demand Touch ID needs an entitlement only a paid developer profile can grant, and the Secure Enclave has no Ed25519 keys. So the item lives in the file-based login keychain with an access list that trusts only the helper's code hash, and the helper checks Touch ID itself under the hardened runtime. Checked by hand on the operator's Mac: 8 checks passed, 0 failed. Another program asking for the key got a keychain dialog and was denied, a re-signed copy of the helper was refused, and a cancelled Touch ID produced no grant. The limits are real (see the evidence block): a process of the same user can delete the key, though not read it, and the build node runs as the operator's user, so its tool policy is a policy, not a sandbox. A dedicated build user with its own deploy credentials is the stronger setup, at the cost of the canary and update tooling that run as the operator.
Evidence: the signer gives a grant only after Touch ID, and only to itself
- Setup
- The operator's Mac, macOS 26.6, the ad-hoc signed helper; a throwaway key under a test label; one run by hand, October 9, 2026.
- Numbers
- 8 checks passed, 0 failed: key created; the helper reads its own key without a dialog; an existing label is not overwritten; a payload that is not a grant is refused before Touch ID; the dialog showed step, node, expiry and digest, and the grant verified with the node's verifier; a cancelled Touch ID gave no grant; another process asking the keychain was denied; a re-signed helper was refused; the throwaway item was deleted.
- Reproduce
- (a) Not with the released binaries alone: the helper is built from source in the closed nfltr repository. (b) Our check, in the closed nfltr repository:
make approval-signerbuilds the helper (needs the Xcode command line tools), and a script there runs the eight checks by hand (four Touch ID prompts); no make target runs it.make test-release-node($0) runs the Go side against a stand-in helper. - Limits
- One machine, one run, checked by hand; no CI runs Swift. It does not stop a process of the same user from deleting the key, and the build node's policy is a policy, not a sandbox.
The first hub-run release found three bugs
v1.0.694 was the first release the hub ran. The owner signed publish, relay and site with Touch ID. Publish took 373 s, relay 235 s with its verify passed, site 41 s and verify 40 s, and v1.0.694 went live. Then the run stopped at the canary step, as the procedure says it should. On the way it found three bugs, each now fixed with a guard:
- The build node was joined without
--allow-all-tools. A real Claude Code agent was refused every shell call before the node's policy was even consulted. Our end-to-end test missed it because the scripted stand-in ignores permission modes. The guide's join line is fixed and checked by a test. - Mac-side scripts needed bash 4. The node's
PATHhad only macOS's bash 3.2, so the gates step failed withmapfile: command not found. Every Mac-side script now runs under bash 3.2, and a check enforces it. - The node's environment leaked into nested nodes. The canary step starts canary nodes from inside the build node's agent. Those nodes read a variable the build node had set for its own agents, the approval key, as their own configuration, refused to start, and the step reported the previous canary record. The fix is general: a node's configuration never comes from the variables a node exports to its agents, and those variables now have their own names. An audit found the same class of leak in four more places.
Evidence: the first hub-run release went live and found three bugs
- Setup
- v1.0.694 on October 9, 2026: the operator's Claude Code session as the hub, one build node on the operator's Mac, the production relay at nfltr.xyz; one run.
- Numbers
- Publish 373 s, relay 235 s (verify passed), site 41 s, verify 40 s; v1.0.694 live; stopped at the canary step; three bugs fixed, each with a guard.
- Reproduce
- (a) Not reproducible outside our production; the run is a one-off live release. (b) Our check, in the closed nfltr repository:
make test-release-hub($0) runs the same machinery against a real relay and a build node with the shipped policy, with a scripted stand-in for Claude: an unapproved publish refused, relay before publish refused, a hub killed mid-step and reattached with the completion delivered once, a spent grant refused, rollback once.make check-bash32is the bash 3.2 guard. - Limits
- One release; the times are mostly upload and deploy time on a home connection, not hub overhead. The fake-model test proves the machinery, not how a real model follows the procedure.
A fourth finding was harmless: the summary step prints RELEASE_SUMMARY where the procedure asks for a RELEASE_STEP line, and the agent returned it verbatim and said so.
The second ran every step ok
v1.0.695 ran on the same hub session and build node. The owner signed all five production grants before the run (five Touch IDs) and deleted the grant file afterwards; rollback was not needed.
| Step | Time |
|---|---|
| preflight | 4 s |
| gates | 67 s |
| publish | 378 s |
| relay (verify passed, v1.0.695 live) | 230 s |
| site | 40 s |
| verify | 41 s |
| canary (three canary machines, first progress p50 12.1 s, completion p50 16.4 s) | 89 s |
| status | 27 s |
Evidence: the second hub-run release ran every step ok
- Setup
- v1.0.695 on October 10, 2026: the same hub session and build node (restarted on v1.0.695 before the canary step), five grants signed up front; one run.
- Numbers
- Eight steps ok in the table above, publish the longest at 378 s; canary first progress p50 12.1 s and completion p50 16.4 s on three canary machines; no retry, nothing outside a step.
- Reproduce
- (a) Not reproducible outside our production (a one-off live release). (b) Our check, in the closed nfltr repository:
make release-hub VERSION=<v> RELEASE_GRANTS=<file>runs a real release and needs the owner's grants, the build node and production access; it costs real model spend for the hub and step agents.make test-release-hubis the $0 check of the machinery;make orch-canaryruns one canary (needsORCH_CANARY_ENV_FILEwith an API key, and the canary nodes). - Limits
- One clean run. Later releases needed occasional re-runs of a step; this one did not.
Limits
- The status page is one machine's view. It is as fresh as the last canary run on the operator's Mac.
- Access through nfltr needs the relay. When the relay is down, so is the access agent; the cloud provider's SSH is the way in then.
- The Linux node lives on the operator's Mac. It is there only while the Mac and its Docker VM run; otherwise the canary records a skip.
- Releases still start at the Mac and need the owner's finger for each production step. A paired phone can already give the hub its go for each step, but the grant itself is still signed on the Mac; grants signed from the phone, so the approval itself is the grant, are a follow-up.
Is it for you?
Fits if you run a small service yourself and want operator access with no open port while keeping your provider's SSH for emergencies, Linux test machines joined from a Mac's Docker VM, or a release run by your own Claude Code session as a hub, where a node's policy, not the prompt, refuses every production step without your signed grant.
Not ready for teams: the release setup is for one owner on one Mac, the signer helper is built from source and needs Touch ID, and the build node runs as the operator's user, so its policy is not a sandbox. The status page is one machine's view, the access agent shares the relay's fate, and the release procedure and step script are ours, in the nfltr repository, which is closed source. Agents are Claude Code only.
Try next nfltr start to join a machine and run a first task, then claude mcp add nfltr -- nfltr mcp --toolset hub to make your Claude Code session a hub (Claude Code, Join machines as nodes). For access with no open port, SSH to a machine behind NAT. Owner-signed grants for a node's changes are in From Alert to Approved Fix. How the relay's metrics reach a laptop through the access agent is the next post in this series, Relay metrics on a laptop, through nfltr; the sites this builds on are in nfltr.xyz now runs on nfltr.