GitHub App¶
The nexus bot is a GitHub App, not a personal access token. Every
write the agents perform (reactions, replies, dashboard edits, PR
operations, asset commits) authenticates with an installation token
minted on demand from the App's RSA private key. This page is the
end-to-end walkthrough: create the App, grant the right permissions,
install it on your asset+issue repo, populate config/nexus.yml, and
verify the wiring.
For the broader public/private split that motivates a per-operator App, see Repos. For the day-to-day identity rules every worker follows, see Security.
Audience
A first-time operator standing up their own nexus. The flow below assumes you have completed the Install prerequisites and already have a private asset+issue repo to point the bot at.
Recommended path: let Claude Code drive
For first-time installs the recommended flow is the Claude-Code-
driven bootstrap — agent-sandbox tmux new-session
./monitor/bootstrap-install.sh — which walks you through every
step on this page interactively, asks back for the values it
needs, generates config/nexus.yml, and runs the smoke tests.
The walkthrough below is the authoritative reference the
bootstrap quotes; keep it open if you want to follow along, or
if you're debugging the bootstrap. See
Install § Step 2.
Why a GitHub App and not a PAT¶
| Property | GitHub App | Personal access token |
|---|---|---|
| Identity | Its own [bot] account |
Your user account |
| Permission scope | Per-repo, per-resource granular | The full scope of your user |
| Token lifetime | ~1 h installation token, auto-refreshed | 30–90 days, manual rotation |
| Notification routing | Mobile push always fires for the user | Muted on actions taken by your own account |
| Eligibility filter | Author-based exclusion is structural | Requires body-prefix conventions |
The last row is load-bearing. The watcher's eligibility filter
(see Security) keys off
comment.author.login == github.user_login — bot-authored comments
are excluded from the directive stream by construction, so the bot
can never read its own output as user input.
Prerequisites¶
ghCLI authenticated as yourself (smoke tests only; the bot path uses minted installation tokens).python3withpyyaml(used byconfig/load.sh).openssl,jq,curl,bash— already required by the watcher.- A writable
~/.claude/directory for the App's RSA private key and the token cache. - An existing asset+issue repo (your bot will install onto this repo
only). Convention:
<your-org>/<your-handle>-nexus-assets, private. See Install for the repo-creation step.
Step 1 — open the App-creation page¶
Solo operator (personal-account App):
Org-owned App (recommended for labs — survives any single admin's account leaving, centralises secret rotation):
Both flows are otherwise identical; the App's owner determines who can rotate its secrets later.
Step 2 — App basics¶
| Field | Value |
|---|---|
| GitHub App name | A distinctive slug — convention <org>-<user>-bot keeps fork attribution legible. The slug becomes the bot's login (<slug>[bot]). |
| Homepage URL | Anything you can claim ownership of (the repo URL works); the bot never links out. |
| Callback URL | Leave blank. The bot does not use OAuth. |
| Setup URL | Leave blank. |
| Webhook → Active | On. See Step 3. |
Step 3 — webhook, secret, and smee channel¶
The watcher reads webhook deliveries from GitHub's authenticated
/app/hook/deliveries endpoint using an App JWT — it does NOT poll
the URL you configure here. The URL only needs to exist so that GitHub
retains every delivery in its log.
- Provision an inert sink. The simplest path is a smee.io
channel: visit
https://smee.io/new, which 302s to a freshhttps://smee.io/<slug>URL. Any reachable HTTP endpoint that returns200works (Cloudflare Worker, self-hosted receiver, etc.). - Paste that URL into the App settings' Webhook URL field.
-
Generate a secret and paste it into Webhook secret:
-
Leave SSL verification ON. Disabling it lets anyone who hijacks the smee channel forge deliveries.
-
Save the secret to a
chmod 600file on the watcher host:echo -n "<paste-secret-here>" > ~/.claude/<bot-slug>-webhook-secret chmod 600 ~/.claude/<bot-slug>-webhook-secretThe path goes into
github.bot_webhook_secret_pathin Step 8.
The webhook secret is informational today
The watcher trusts GitHub end-to-end over the App-JWT channel and does not currently HMAC-verify deliveries. The secret slot exists so that rotation becomes a config change rather than a code change once a self-hosted receiver verifies signatures locally. Keep perms tight regardless.
Step 4 — permissions¶
Grant exactly what the code below requires. Each row traces to a
concrete call site in monitor/; nothing else is needed, and leaving
the rest ungranted keeps the bot's blast radius small.
| Permission | Grant | Code path | Why |
|---|---|---|---|
| Repository → Metadata | Read | Implicit — every gh api /repos/:r/... resolves through it; monitor/mint-token.sh's output is unusable without it |
Required by every GitHub App. |
| Repository → Issues | Read & Write | monitor/ng process\|react\|reply\|close\|dashboard ...; monitor/watcher/_github.sh:snapshot_github (GraphQL search(type: ISSUE) + comment-reactions fetch) |
The dashboard is issue-body content; the eligibility filter reads comment bodies and reactions; every directive turn reacts 👀/🚀, posts replies, closes threads, or patches the overview body between the <!-- NEXUS_DASHBOARD_START --> markers. |
| Repository → Contents | Read & Write | monitor/upload-asset.sh: git clone / git fetch / git push against https://github.com/<asset-repo>.git (the installation token reaches git through an inline credential helper, never the URL, #1840) |
Pushing to the asset repo's main branch under assets/... requires Contents write. Without it git push origin main returns 403. |
| Repository → Pull requests | Read & Write | monitor/ng pr create\|edit\|merge\|view ...; worker invocations of GH_TOKEN=$(./monitor/mint-token.sh) gh pr ... |
Bot-authored PR operations 403 without this scope. Used by worker agents that open PRs on their feature branches. |
| Organization → Members | Read | gh pr edit invoked as the bot against a PR in an org-owned head repo |
gh pr edit validates the authenticated identity's edit rights by checking org membership before issuing the REST call. Without this scope the CLI errors out client-side. |
Skip every other permission GitHub lists (Deployments, Actions,
Secrets, Variables, …) — nothing in monitor/ touches them.
Step 5 — subscribe to events¶
Subscribe to exactly these five event kinds; the deliveries-polling
watcher (monitor/watcher/_deliveries.sh, gated by
monitor.deliveries.*) folds them into its line-shape vocabulary:
issuesissue_commentpull_requestpull_request_reviewpull_request_review_comment
Subscribing to more events just inflates /app/hook/deliveries
without surfacing anywhere. Subscribing to fewer drops directives
on the floor.
Step 6 — install location and private key¶
- Where can this GitHub App be installed? — choose Only on this account. This restricts the install to the org or user that owns your asset+issue repo.
- Click Create GitHub App.
-
On the new App's settings page, scroll to Private keys and click Generate a private key. A
.pemfile downloads. Move it to the watcher host and store it with tight perms:mv ~/Downloads/<bot-slug>.*.private-key.pem ~/.claude/<bot-slug>.pem chmod 600 ~/.claude/<bot-slug>.pemThe path goes into
github.bot_pem_pathin Step 8. Treat this file like an SSH private key. Rotation: see below. -
Capture the App ID at the top of the settings page (7-digit integer); it becomes
github.bot_app_id.
Step 7 — install the App on your asset+issue repo¶
- Click Install App in the App's left-nav.
- Choose the account that owns your asset+issue repo.
- Restrict the install to that single repo. Do NOT install on
<your-org>/nexus-code— your bot is per-operator and writes only to your own asset+issue repo. The code repo carries no per-operator state. - After install, GitHub redirects to a URL of the form
https://github.com/settings/installations/<n>orhttps://github.com/organizations/<org>/settings/installations/<n>. The trailing<n>isgithub.bot_installation_id.
One install per operator
A single GitHub App can be installed on multiple repos, but each nexus deployment should run its own App. Sharing an App across operators couples their token caches and conflates audit trails. For sibling labs / forks, repeat the entire flow per fork — see Cross-fork variant below.
Step 8 — populate config/nexus.yml¶
Edit the file. The minimum to fill in:
nexus:
root: <absolute-path-to-this-clone>
github:
user_login: <your-github-login>
repo: <your-org>/<your-handle>-nexus-assets
bot_app_id: <APP_ID> # from Step 6
bot_installation_id: <INSTALL_ID> # from Step 7
bot_pem_path: ~/.claude/<bot-slug>.pem
bot_token_cache: ~/.claude/.nexus-bot-token.json
# ^ pick a unique filename per nexus if you run multiple on one host.
bot_git_name: <bot-slug>[bot]
bot_git_email: <bot-slug>[bot]@users.noreply.github.com
bot_login: <bot-slug>
bot_webhook_url: https://smee.io/<your-channel>
bot_webhook_secret_path: ~/.claude/<bot-slug>-webhook-secret
monitor:
deliveries:
asset_enabled: true
bot_mention_enabled: true
github.asset_repo defaults to github.repo, so leave it commented
unless you intentionally want assets in a different repo from issues
(rare). Other knobs — Pushover, ntfy, email, idle thresholds — are
covered in Config.
Step 9 — smoke tests¶
Run each command in order from the nexus root. Each step is independently re-runnable; fix the cause and re-run before moving on.
# 1. Mint an INSTALLATION token (a `ghs_...` value, not a JWT).
./monitor/mint-token.sh
# 2. App-JWT smoke — this one IS a JWT (three dot-separated segments).
# Used by the deliveries probe.
./monitor/mint-token.sh --jwt-only
# 3. Resolve an issue via the bot token. Both `#1 state=OPEN|CLOSED title=...`
# and `ng: issue 1: fetch failed` (an empty repo) prove the token
# mints and the App can read the repo.
./monitor/ng issue 1
# 4. Bot install scope.
./monitor/ng preflight "$(./config/load.sh github.repo)"
# expected: "bot installed: yes (<repo>) — repository_selection=..."
# 5. End-to-end asset upload. Commits one file to the asset repo's
# main branch and prints the SHA-pinned URL.
./monitor/ng upload README.md --message "preflight"
If all five succeed, the bot mints tokens, reads your asset+issue repo, and can push commits as the App identity. You're ready for the watcher.
Common failure modes¶
| Symptom | Cause | Fix |
|---|---|---|
private key not found |
bot_pem_path is wrong or the file moved |
ls -l "$(./config/load.sh github.bot_pem_path)" must show -rw-------. Re-chmod 600. |
REFUSING to sign … verdict=exposed (exit 4) |
the key is world-accessible and every ancestor directory is world-traversable, so other local users can read it ( |
chmod 600 "$(./config/load.sh github.bot_pem_path)", then rotate — a chmod does not un-expose a key that was readable. ./monitor/mint-token.sh --check-key reports the verdict without minting. |
Bad credentials on the deliveries probe |
bot_app_id doesn't match the pem you downloaded |
Recheck the App page; the App ID is at the top. |
ng preflight returns bot installed: NO |
App is not installed on the asset+issue repo | Re-do Step 7. |
ng upload 403 on push |
Contents permission granted on the App but not installed on the asset repo | Same — re-do Step 7. |
ng upload 404 cloning the asset repo |
The repo doesn't exist on GitHub, or github.repo is misspelled |
gh repo view "$(./config/load.sh github.repo)". |
gh pr edit fails with Resource not accessible by integration or could not look up members of organisation |
Missing Organization → Members: read permission | Add it via App settings, accept the new-permission request on the installation. |
Deliveries probe returns [] after --jwt-only |
Expected on a fresh install | Trigger any subscribed event in the asset repo (react :thumbsup: on any issue) and re-run the probe. |
Step 10 — verify webhook subscription end-to-end¶
Trigger any subscribed event in your asset+issue repo:
- Open any issue in
github.repo. - Add a
:thumbsup:reaction to the issue body. - Wait ~5 s for GitHub to record the delivery.
Then re-run the deliveries probe:
JWT=$(./monitor/mint-token.sh --jwt-only)
curl -sS \
-H @<(printf 'Authorization: Bearer %s\n' "$JWT") \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/app/hook/deliveries?per_page=3" \
| jq '.[].event, .[].status_code'
The newest entry should show:
A non-200 status code means the smee channel is unreachable or
deleted. GitHub still records the delivery either way; the watcher
reads from /app/hook/deliveries directly, so non-200 only matters
if you ever wire up a self-hosted receiver.
First-time overview-issue setup¶
The install bootstrap (./monitor/bootstrap-install.sh, Phase 5) does
this for you using the bot's installation token once the Phase 4 smoke
tests pass. You only need this section if you skipped the bootstrap
and are wiring the asset+issue repo by hand, or if you're re-running
the bootstrap with --force and want to know what the agent will
execute.
The monitor discovers the overview issue by label, not by number or title. One-time setup on a fresh asset+issue repo:
- Create the label
nexus:overview(mandatory). The bootstrap also createsnexus:active,nexus:decision, andnexus:blockedas thread-organising defaults; colours and descriptions match the reference deployment<your-org>/<your-nexus>. -
Open a new issue titled
Nexus, apply thenexus:overviewlabel, and use a placeholder body with the dashboard markers: -
The first
./monitor/ng dashboard put --body-file <file>splices new content between the markers, preserving any static prose around them. - Pin the issue so it stays at the top of the Issues tab. The
bootstrap tries
gh issue pinunder the bot token first; if GitHub refuses (some org-policy combinations 403 App-authenticated pin mutations), it falls back to a one-line "pin in the browser" nudge — pinning is cosmetic and doesn't affect runtime correctness.
--force re-bootstrap idempotency: labels are created via the REST
API (gh api repos/$repo/labels, portable to the gh 1.13.0 in the
sandbox base image) and PATCHed in place on a 422; the bootstrap
reuses any existing open nexus:overview issue before creating;
gh issue pin is a no-op on already-pinned issues.
Secret handling and rotation¶
Files the bot writes to¶
| Path (default) | Purpose | Perms |
|---|---|---|
~/.claude/<bot-slug>.pem |
App private key — signs the JWT that mints installation tokens | 0600 |
~/.claude/.nexus-bot-token.json |
Cached installation token + expiry. Re-minted within 5 min of expiry. | 0600 (set by the mint helper) |
~/.claude/<bot-slug>-webhook-secret |
HMAC secret matching the App settings' webhook field. Informational today; load-bearing once a self-hosted receiver verifies signatures. | 0600 (or 0660 for a bot-uid group) |
config/nexus.yml |
Workspace config; holds non-secret IDs and paths to the above files | 0600 |
None of these belong inside monitor/.state/ (which is shared with
the watcher's runtime state) and none should ever be committed.
Rotating the private key¶
If the .pem leaks or you suspect compromise:
- On the App settings page, scroll to Private keys → click
Generate a private key. A new
.pemdownloads. (You can have multiple active keys briefly; the App accepts JWTs signed by any of them.) - Replace the file at
github.bot_pem_pathwith the new one (chmod 600). - Mint a token to confirm:
./monitor/mint-token.shshould print a fresh installation token (ghs_...). Note the token cache atgithub.bot_token_cachemay still hold a valid token from the old key — delete it if you want to prove the new key signs. - On the App settings page, delete the old key to invalidate any in-flight JWT signed with it.
- The next watcher cycle picks up the new key automatically — no
restart needed because
mint-token.shre-reads the pem on every mint.
Rotating the webhook secret¶
- Generate a new value:
openssl rand -hex 32. - Paste it into the App settings' Webhook secret field.
- Overwrite the file at
github.bot_webhook_secret_pathwith the same value. - The deliveries log keeps flowing because GitHub records deliveries regardless of receiver outcome; nothing in the watcher reads the file today, so rotation is hygiene rather than load-bearing.
Rotating the installation¶
If the bot is installed on the wrong repo, or you need to revoke its
access entirely, uninstall the App from
https://github.com/settings/installations (or the org equivalent).
This invalidates every outstanding installation token. Re-install per
Step 7 to restore.
Cross-fork variant¶
When a sibling lab forks the nexus pattern (creating a separate
<their-org>/<their-handle>-nexus-assets repo and running their own
operator on their own host), each fork needs its own GitHub App
and installation. The code is shared (one clone of nexus-code
per operator, all from the canonical implementation repo); the
per-fork values are:
- A separate App with its own App ID.
- A separate installation on the sibling fork's asset+issue repo.
- A separate
.pemfile. - A separate webhook secret file.
- A separate smee channel — do not share.
- A unique
bot_token_cachefilename (e.g.~/.claude/.<other-handle>-bot-token.json) if both watchers run on the same host. Otherwise per-fork tokens will overwrite each other.
The <bot-slug>, <org>, <repo>, and <user-login> are by
definition fork-specific. Everything in this guide applies verbatim;
repeat Steps 1–10 against the new fork's org and repo.
Discoverability via the nexus-fork topic¶
Cross-fork bug-fix pings (a fix landing in one fork that siblings
should pull) need a way to enumerate the live forks. The convention
is the GitHub topic nexus-fork. After standing up a fork, tag it:
Run as a user with admin rights on the fork. (The bot's installation
token typically can't write repo topics on repos it isn't installed
on; a maintainer's gh PAT is the right channel for the initial
tag.)
Verify siblings show up:
What the bot can read and write¶
The bot is not a general-purpose GitHub identity. Its access is bounded by:
- Install scope — the single asset+issue repo you ticked in Step 7. The token cannot resolve any other repo.
- Permission scope — the five rows in the permission table. Outside those, the token returns 403.
- Authority scope — the watcher only surfaces comments authored
by
github.user_login. A bot with all the right permissions still acts only on that one user's directives. See Security → The eligibility filter.
If the bot ever needs to operate outside this envelope (e.g. open a
PR on <your-org>/nexus-code from a worker), the worker uses
GH_TOKEN=$(./monitor/mint-token.sh) gh ... and the App must be
installed on that target repo too. The
monitor/ng preflight verb tells
you whether a given repo is in the install scope.
Related¶
- Repos — the public/private split and why the asset+issue repo lives separately.
- Security — the eligibility filter, the three authorization tiers, threat model.
- Monitoring — verifying the bot is alive, log locations, the deliveries probe in operational use.
- Config — every
github.bot_*key and its env-var override. - ng CLI — verb table including
preflight,upload,dashboard,watcher-status.