Hermes: the issue board I ran from my phone

June to July 2026Retired

Hermes was a phone-driven approval loop for agent coding sessions: a Telegram bot, used by me alone, over the GitHub-issue pipeline that shipped work into Homies. It relayed the decisions only I could make, dispatched allowlisted work, and ran sessions in isolated worktrees. What it taught me is in section 2: its last gate read a missing CI answer as a passing one.

It was retired in July 2026, and the issue pipeline around it is no longer used. It was host-capable software running on my own machine, so the security boundary got as much attention as the features.

The issue was the runtime

The design decision the rest of it hung from: the issue, and never the session, was the source of truth. Each piece of work lived in a GitHub issue moving through a small label vocabulary, needs-grill, grilling, waiting-input, ready, implementing, pr-open, finishing, blocked. A label set turns agent work into a state machine something else can inspect, which a pile of chat transcripts never becomes.

Every session also wrote one structured ledger comment on its issue, edited in place: decisions, assumptions, questions asked, answers received, the handoff note, the outcome. lib/ledger.mjs renders and parses that comment, and the render has to round-trip losslessly, because the bot reads its own comment back as often as it writes one. That comment is what made sessions disposable: an issue carrying labels, a PR link and a ledger survives a run dying halfway through, and a context window does not survive anything.

Work was split by job rather than handed to one large agent. A grilling session read the repository and interrogated the issue, settling what the code could settle and escalating only owner-class calls. A ready issue dispatched an implementer against one repository on one branch. A finisher took the PR from there.

Where it did not fail closed

A security review in July found the hole. The finisher read CI state as three counts, total, pending and failed, and every one of them defaulted to zero when the response was missing. So an API error, a cancelled run, or any answer the bot could not parse arrived as zero checks, zero pending, zero failed, and the pipeline fell through to merge. The comment above that branch said a PR with no checks configured counts as green, which was true, and which made the absent case read as the empty case. A missing answer was being treated as a passing one, in the exact stage that was supposed to be the last gate.

The same review found the audit approval had no identity attached: an audit that approved a PR stayed approved after new commits landed on it. One commit fixed both. An unavailable or malformed checks response became a wait rather than a pass. The audit now recorded the head SHA it approved, and a PR whose head had moved went back through the audit stage. The finisher's CI stage, before and after:

- const total = checks?.total ?? 0;
- const pending = checks?.pending ?? 0;
- const failed = checks?.failed ?? 0;
+ if (!checks || checks.available === false) {
+   return { action: "wait", reason: checks?.reason || "CI check state unavailable", state: s };
+ }
+ const total = checks.total;
+ const pending = checks.pending;
+ const failed = checks.failed;
+ if (![total, pending, failed].every(Number.isFinite)) {
+   return { action: "wait", reason: "CI check response malformed", state: s };
+ }
  ...
- // total === 0 (no checks configured) counts as green; otherwise all present checks passed.
+ // total === 0 is allowed only after a real, available checks response.
  s = toStage(s, "merge");

Two tests came with it: nextFinisherStep: unavailable or malformed CI state waits instead of merging and nextFinisherStep: approved audit is reset when PR head changed.

Nothing shipped wrong because of it. The review found it before an incident did, and no stale merge is known to have happened. It is the most useful thing Hermes taught me, because it is the failure mode fail-closed systems actually have: the gate was present, wired in, and tested, and it answered with confidence in the one case where it had nothing to answer with.

The question it existed for

Here is the round trip that justified the whole build. In July a grilling session on the app repository found that billing banners meant for paying members were also showing to members covered by someone else's plan, because a status call inherited the owner's failed-payment flags onto everyone they covered. Tapping one sent a covered member to an empty store subscriptions page.

The session could work out the fix from the code. What it could not work out was the product call: once the actionable banner is hidden from covered members, what should they see while the owner's payment is failing? It asked, with both options spelled out and a recommendation attached. The answer came back eighty-four seconds later: show nothing this ship, because the neutral read-only banner already covers the moment coverage actually lapses, and the alternative adds coverage-attribution complexity that should not gate a release.

Two more questions followed in the next two minutes, one on severity color and one on which engine to dispatch, and the issue went out settled. That is the shape the system was for. An agent does not need someone at a terminal for every product ambiguity. It needs somewhere to put the question where an answer will actually arrive, and a way to carry that answer back into the live session.

What a merge tap actually did

Tapping merge on my phone did not run git merge. It granted permission to start the finisher pipeline, which then did whatever was actually required: resolve conflicts, get a cross-engine audit from the engine that had not written the code, wait out CI, and merge only if all of that held. Each failing stage got exactly one retry through a session. A second failure parked the issue as blocked with a plain-language reason rather than looping unattended and burning tokens.

The same principle ran underneath the buttons. A callback on the push button was bound at issue time to the head SHA of the coding result it referred to, carried as expectedSha on a short-lived single-use capability. By the time a push executed, the SHA had been checked three times: the capability had to match the binding it was issued against, the worktree's live HEAD had to still equal it, and the push itself named the commit explicitly rather than pushing whatever HEAD had become. The merge call passed the audited SHA to the GitHub API, so GitHub refused the merge if the head had moved. An approval issued against one commit could not be replayed against a newer one.

Hermes guardrails and how each was enforced
GuardrailMechanical enforcement
One authorized operatorExact chat and sender checks on every message and every callback. Possession of the phone was not authority.
Sensitive actions needed step-upTOTP plus a short-lived single-use capability bound to identity, action, repository, target, message, and where relevant the live SHA.
Remote compromise could not unlockLocking was allowed from the phone. Unlocking was local-only, and first install or state loss started locked.
Child processes saw lessExplicit minimal environment allowlists instead of inheriting the service environment.

Why I stopped

The ceremony outgrew the work. Labels, a grilling stage, a ledger comment, a finisher pipeline and a phone relay are a lot of machinery for one person to maintain around their own side project, and the maintenance was not free. The last four days of commits before it stopped are almost all hardening: file permissions, capability tokens, engine trust boundaries, fail-closed checks. None of it shipped a feature. A lighter setup replaced it, keeping the control and dropping most of the apparatus.

What I would carry into the next one is the inversion rather than the implementation. Durable state belongs in an artifact the rest of the workflow already inspects. Each stage gets a narrow job. The write boundary is a pull request, and the thing that opens it is not the thing that wrote the code. Approval is evidence about one commit rather than a standing permission. The engineering arm that opened those pull requests is the Autopilot, and the app they landed in is Homies.