SIGNAL: NOMINALCH: BLOG.DROIDKLUSTER

Every agent gets its own GitHub App

The Droidkluster

Many homelabbers are running (or starting to run) LLM Agent fleets for various workloads. One such fleet that I run is agents against my GitHub repos for an adversarial CI/CD pipeline to drive productivity. It consists of a CI-failure diagnostician, a code reviewer, a merge gatekeeper, and an operator interaction bot. I started this journey as many of us homelabbers have. One agent, doing multiple roles, posted as one bot account holding one broad PAT. That arrangement failed me in two ways at once. And it pointed to the obvious need for agents to have their own identity and control plane just like regular users do.

First, the attribution problem. When every comment on a PR is authored by the same identity, “What agent/process said this?” becomes an archaeology exercise. A review verdict, a CI diagnosis, and a merge-gate refusal all look like the same actor, with only their content to distinguish. When one of them misbehaved, I was relying on their own self-identification in comments/logs, nothing authoritative and easily forgotten (or if comments are being truncated, now who’s not signing off?)

Second, the blast radius. That PAT lived in the environment of every worker pod that needed GitHub. Any one compromised worker leaks a long-lived credential with write access to everything. One bad dependency, an agent prompt-injected into running printenv, an unremembered misconfigured sidecar. And given I’m using LLM workers consistently, I can tell you these are exactly the kind of process you should assume will eventually do something dumb with whatever is in their environment. I had already been burned once by a similar situation: integration-test credentials injected at the runner-pod level meant any workflow running on that runner could read them. One rule I’ve developed when working with LLMs: Generalize the concept. Any bugs that come up, if you go and attack just the bug you are nearly guaranteed to play whack-a-mole. If an LLM agent introduces a bug/issue, you must find the generalized concept of the bug to truly remove it (or else the same class of bug will re-introduce on another agent run).

The fix I landed on seemed obvious: every agent persona is its own GitHub App. But I wasn’t satisfied with this from a credentials standpoint. I wanted to be able to easily control what agents had access to what (which ACLs for GitHub apps is nothing new) and similarly be confident that any agent couldn’t cross boundaries. i.e. my code reviewer has no write access, but my CI Doctor does because that agent is authorized to do lint fixes and minor changes to allow CI processing. I wanted to make it so a worker doesn’t actually know what their password/credentials are. The answer I landed on was a single small broker holds the App private keys and vends short-lived installation tokens over NATS request/reply. I call it gh-app-mint, and I’ve now extracted it from the production fleet and open-sourced it in hopes that others might benefit from the concept/experience.

Why personas at all

I have found, anecdotally, that LLMs actually respond better to “Our code reviewer found the following:” as opposed to “these are our issues:” The difference lies in accountability and attribution, which an LLM can pick up on, even if it recognizes that it’s referring to another LLM.

The GitHub-facing personas are named after Star Wars droids, because a homelab is allowed to be fun and because genuinely, I think it fits how we should be considering the work that LLMs do. We need to be able to personify them to some degree (it’s how our systems are currently built, and how we tap into those systems to scale). But it also allows for my ADHD brain to be able to remember what program/agent/process is doing what. But with great power comes great responsibility: notice I’m not calling them human names. I’m not trying to give them human emotion and I’m not trying to humanize them.

Thus I started to move towards having individual personas that could be easily referenced by any LLM helping me, as well as the agents themselves. The naming rules for the droids was fairly simple: it must in some way reflect the job that I have them doing. Even that decision is impactful for the LLM. For example, using HK-47 as the code reviewer automatically locks in what the LLM knows about HK-47 from its training data (Sith-built assassin droid…FIND AND DESTROY). So now when the code review agent runs, it brings along all its training data (and we know the Star Wars corpus is vast in LLM training) for free. Meaning a coding agent sees that the review wasn’t just “a code review found” it’s “an assassin droid found code issues meatbag” which I’ve found to lead to beneficial responses on both the reviewer side (HK really does call out bad coders for being meatbags which is beneficial for my health). For the reviewee side, while I can’t fully isolate it to the persona effect among the other changes, it definitely didn’t hurt.

The persona model

My control plane has had an identity invariant since before this service existed. Outside the homelab, observers see a small set of named bot identities. Inside, everything decomposes into role-bound service accounts that map 1:1 to Deployments. There’s a second invariant sitting right next to it: one credential per pod. Adapters hold exactly one upstream credential, and workers hold zero. If I ever mount a worker with both a GitHub PAT and a Discord bot token, that’s a design violation, full stop.

Each is a separately registered GitHub App with its own App ID, its own private key, and its own permission grant. The reviewer App can write reviews; it cannot merge. The fleet has since grown a persona for PR-lifecycle work and a deliberately read-only App (pull_requests:read + metadata:read) for a dashboard reconciliation sweep that only ever lists PRs. Least privilege stops being a policy document and becomes a property of the App registration itself.

The broker

gh-app-mint is a single-replica sidecar service. It is the only process in the cluster that ever touches a GitHub App private key. The PEMs are mounted read-only from the secret store via ExternalSecret, read once at boot, never logged, never returned in any response, never written anywhere else.

Workers that need to act on GitHub make a NATS core request/reply call (deliberately not JetStream, and more on that below):

Subject gh-app-mint.token.<persona>
Request {repo: string, installation_id?: number}
Reply (ok) {token, expires_at, installation_id}
Reply (err) {error, code} where code is one of persona_not_loaded, bad_request, installation_lookup_failed, mint_failed, internal

The broker signs an App JWT with the persona’s private key (a local operation, no network), exchanges it with GitHub for an installation token, and returns that. Installation tokens have a one-hour TTL and are scoped to exactly the permissions the App was granted on exactly the repos it is installed on. That token is the only GitHub credential a worker ever sees.

One design decision worth calling out: the persona is parsed from the subject, not from the request payload.

/** Extract persona from `<prefix><persona>`, e.g. `gh-app-mint.token.hk-47`. */
export function personaFromSubject(
  subject: string,
  prefix: string = DEFAULT_SUBJECT_PREFIX,
): string | null {
  if (!subject.startsWith(prefix)) return null;
  const tail = subject.slice(prefix.length);
  // Reject deeper wildcards (e.g. `token.<persona>.extra`) — keep the
  // surface narrow for ACL clarity.
  if (tail.length === 0 || tail.includes('.')) return null;
  return tail;
}

This matters because NATS NKey ACLs authorize by subject. If the persona lived in the JSON body, any worker allowed to talk to the broker could ask for any persona’s token. With the persona in the subject, the ACL can say: the TT-8L worker may publish only to gh-app-mint.token.tt-8l. Impersonation across personas becomes a bus-level permission error, not something the broker has to police in application code. The broker’s own ACL is equally narrow: subscribe to gh-app-mint.token.>, publish only to its dead-letter subjects and _INBOX.> for replies.

Caching, and the five-minute margin

Workers mint per-action, and many actions land tens of seconds apart on the same PR. Round-tripping GitHub for every request would be wasteful, so the broker keeps an in-memory cache keyed on (persona, installation_id). The interesting bit is the eviction rule:

/** Returns the cached token if it has more than refreshLeadMs remaining,
 *  else undefined (caller mints fresh). */
get(k: CacheKey): CachedToken | undefined {
  const hit = this.store.get(key(k));
  if (!hit) return undefined;
  const remaining = hit.expiresAt.getTime() - this.now();
  // Negated comparison so a NaN `remaining` (an unparseable expiry from
  // GitHub) falls through to the miss path — `NaN < x` is false, so the
  // direct comparison would turn a corrupt entry into a permanent hit.
  if (!(remaining >= this.refreshLeadMs)) {
    this.store.delete(key(k));
    return undefined;
  }
  return hit;
}

That negation is deliberate, and it is the kind of thing an adversarial review catches: written as remaining < refreshLeadMs, an unparseable expiry date makes remaining NaN, every comparison against it is false, and the corrupt entry becomes a cache hit that never expires. The fail-safe direction has to be the one NaN falls into.

refreshLeadMs defaults to five minutes. A cached token never gets handed out with less than five minutes of life left, so the caller always has a window to actually use it before it expires. Without that margin you get the classic flake: the token is valid when it’s vended and expired by the time the worker’s third API call goes out.

There’s a second, smaller cache for installation-ID lookups ((persona, repo) → installation_id, five-minute TTL), because resolving a repo to an installation requires its own JWT-authenticated call to GitHub, and the mapping only changes if you uninstall and reinstall the App.

Both caches are process-local Maps. That’s fine at one replica, and it’s explicitly documented as the thing to swap for Redis if the service ever scales out. Parallel replicas would still be correct (GitHub tolerates concurrent installation tokens), just less efficient.

Degraded boot: deploy the broker before the Apps exist

The detail I’m most pleased with is the way I solved organic growth. Registering GitHub Apps is manual, click-heavy work, and I don’t necessarily know all of the agents that I’m going to need in advance before deploying anything. So personas load independently:

gh-app-<persona>-id   → numeric App ID   (env GH_APP_<PERSONA>_ID)
gh-app-<persona>-pem  → PEM private key  (file, read once at boot)

If a persona’s secrets aren’t authored yet, that persona boots unloaded and every other persona serves normally. Requests for the missing one get a clean persona_not_loaded reply instead of a crash loop. Boot-time load failures are captured per-persona, not thrown. A malformed PEM for HK-47 does not take down token minting for 2-1B. The health endpoint reports exactly which personas are live.

This meant the broker shipped and ran in shadow before a single App existed, and I created the Apps one at a time over the following days as each downstream worker came online.

One production scar worth sharing: the subject prefix gh-app-mint is load-bearing. My JetStream streams capture broad wildcards like gh.> and *.command.>. NATS treats a hyphen as part of a token, so gh-app-mint matches neither, deliberately. An earlier subject shape did match a stream filter, and JetStream intercepted every mint request and acked it with a publish-ack envelope; the broker’s reply never reached the caller. The bug sat latent until the merge gatekeeper exercised the path on a real CI failure. If you mix core request/reply and JetStream on one bus, audit your stream filters against your request subjects.

The blast-radius argument

Before:

Compromising any GitHub-touching worker yielded a long-lived PAT with broad write scope. Poison that worker and it’s game over across every repo it could reach, until I noticed and rotated it.

After:

Compromising a worker yields, at most, one installation token. It’s scoped to one App’s permissions on one installation, it’s dead within an hour, and often within minutes given the re-mint margin. No PEM, no PAT, no secrets bundle. The private keys live in exactly one single-purpose process, and that process’s entire API surface is “exchange a NATS request for a token.” That is a dramatically easier thing to reason about than N workers each importing a GitHub SDK with ambient credentials.

The debugging bonus

I built this for containment and got observability for free. Because each persona is a distinct App, every GitHub comment arrives pre-attributed: 2-1b-droidkluster[bot] is a CI diagnosis, hk-47-droidkluster[bot] is a review verdict, tt-8l-droidkluster[bot] is the merge gate. When something looks wrong on a PR, the author line tells me which subsystem to go debug before I’ve opened a single log. My project docs now literally say “comment authorship is the fastest signal” for debugging the pipeline. Identity-per-agent turns out to be a tracing primitive, not just a security one.

It’s not novel!

The industry is converging on exactly this shape. The SPIFFE-for-agents crowd is arguing for per-agent workload identity with short-lived credentials. Microsoft ships Entra Agent ID, which is a directory-native identity per AI agent. And at least one other independent practitioner landed on the same conclusion for the same reasons: giving a coding agent its own GitHub identity.

What surprises me is the gap on GitHub’s side specifically. GitHub Apps are the right primitive: scoped permissions, short-lived installation tokens, distinct bot identity. But there is no product that says “here are your N agent identities, here’s the broker, here’s the audit view.” Everyone building an agent fleet against GitHub is hand-rolling this. The primitives are ten years old; the packaging doesn’t exist.

The code

So I’m publishing mine. The broker is now open source at github.com/droidkluster/gh-app-mint under Apache-2.0, pulled straight out of the production fleet with the degraded-boot contract, both caches, and the subject-based persona parsing intact. It assumes NATS, Node, and a secret store that can mount PEMs as files. Everything else is dependency-injected and covered by tests that stub the GitHub calls.

If you want to see the fleet it came from doing real work, the live board is at whatis.droidkluster.com.