Minsky supports a GitHub App service account (minsky-ai[bot]) for automated operations such as PR review submission. Using a bot identity rather than your personal access token provides several advantages:
- Stale approval dismissal: GitHub can be configured to dismiss pull request approvals when code changes; reviews from a bot identity are unaffected, keeping human approvals valid after bot activity.
- Clear attribution: Automated comments, reviews, and merges are visibly attributed to the bot, not to a human user.
- Hosted service model: A future hosted Minsky service can install a single central App across customer repositories without requiring access to any user's personal token.
Minsky uses a TokenProvider interface to abstract GitHub API token acquisition. Two implementations exist:
FallbackTokenProvider— uses your personal access token for both user and service operations. This is the default when no service account is configured.GitHubAppTokenProvider— authenticates as a GitHub App installation for service operations (posting reviews, creating/merging PRs) while still using your personal token for user-attributed operations.
When github.serviceAccount is present in the Minsky configuration, createTokenProvider automatically selects GitHubAppTokenProvider.
- A GitHub account with owner access to the target repository (required to create and install a GitHub App)
- Minsky configured and working for the target repository with the
githubbackend
You can create and install the App either via the minsky setup github-app CLI subcommand (recommended), the equivalent bun scripts/create-github-app.ts script (for fresh checkouts before the CLI is installed), or through the GitHub UI as a manual fallback. The CLI/script covers sections 1–3 (create App, generate key, install on repo, fetch installation ID) in a single browser-driven flow and saves all credentials to ~/.config/minsky/ with correct permissions.
Run the subcommand with the App name and target repo. Minsky renders the manifest, spins up a local callback listener, opens your browser to GitHub's "Create App from manifest" page, captures the redirect, exchanges the code for credentials, and writes them to ~/.config/minsky/<name>.{pem,json}. A manifest preview is shown before submission so you can confirm exactly what will be created.
Canonical invocations:
# Implementer App (code author, PR creator; no webhook needed):
minsky setup github-app \
--name minsky-ai \
--repo <your-owner>/<your-repo> \
--inactive
# Reviewer App (Chinese-wall adversarial reviewer, mt#1073; webhook-driven):
minsky setup github-app \
--name minsky-reviewer \
--repo <your-owner>/<your-repo> \
--permissions pull_requests:write,contents:read,metadata:read \
--events pull_request,issue_comment \
--webhook-url https://minsky-reviewer.example.com/webhook
# Guided wizard fallback (GitHub Enterprise instances, restricted SSO orgs,
# air-gapped setups — anywhere the manifest flow does not apply):
minsky setup github-app \
--name minsky-reviewer \
--repo <your-owner>/<your-repo> \
--via wizardThe wizard variant walks you through the manual portal steps interactively, prompts for the App ID / installation ID / PEM contents, and validates the pasted credentials against the GitHub API before saving.
If GitHub creates the App but you haven't yet installed it on the target repo when the redirect fires, the local server stays running and serves a /check-install endpoint. The browser shows an Install App link and the URL to revisit:
- Click Install App and complete installation in the GitHub UI.
- Return to your terminal's localhost tab and visit
http://localhost:<port>/check-install. The provisioner re-queries/app/installations, captures the installation ID, and writes credentials to~/.config/minsky/<name>.{pem,json}.
If you close the browser without finishing the install or miss the 5-minute deadline, the App will exist on GitHub but no local credentials will be saved. To recover:
- Same App name: re-running
minsky setup github-app --name <name> --forcewill start a fresh manifest flow. GitHub may reject creating a second App with the exact same name; if so, choose a different--nameor delete the orphaned App in your GitHub developer settings first. - Different name: re-run with a new
--namevalue to provision a fresh App.
Equivalent flag surface; useful before the CLI is installed:
bun scripts/create-github-app.ts \
--name minsky-ai \
--repo <your-owner>/<your-repo> \
--inactiveThe script writes:
~/.config/minsky/<name>.pem(private key,0600)~/.config/minsky/<name>.json(App ID, slug, client ID, installation ID, creation timestamp)
Flags:
--name <name>— required. Also used as file prefix under~/.config/minsky/.--repo <owner/repo>— required. Owner is matched against the install account during installation lookup.--permissions <k:v,...>— optional. Default:pull_requests:write,contents:write,metadata:read(contents:writeis required forsession_commit's App-token push, mt#1477/mt#3210/mt#3218).--events <e1,e2,...>— optional. Default: none.--webhook-url <url>— optional. Prefillshook_attributes.urlin the App manifest. Use this for webhook-driven Apps (reviewer, automation services). Without it, a placeholder URL is submitted (GitHub requires the field).--inactive— optional. Creates the App withhook_attributes.active=false. Default: active. Use this for Apps that don't need webhooks (theminsky-aiimplementer App). Note that GitHub's REST API has no endpoint to toggleactivelater, so choose correctly up front — the only remediation is a manual toggle in the App settings UI.--port <n>— optional. TCP port for the local manifest-flow callback. Must be 1-65535; port 0 is rejected because GitHub embeds the redirect URL in the manifest before the server binds. Default:9847.--force— optional. Re-provision even if credentials already exist for<name>. Without--force, the orchestrator short-circuits and prints the existing credentials.--help/-h— print usage.
The same flags are accepted by minsky setup github-app, plus --via {manifest|wizard}, --apiBaseUrl <url>, and --webBaseUrl <url> (for GitHub Enterprise hosts when --via wizard).
GitHub's REST API has no endpoint to update an existing App's default_permissions or
default_events — modifying an already-created App's registration
(docs)
is exclusively a web-UI procedure, and even then a permission change is only a request: every
account where the App is installed must separately accept it before it takes effect. There is
nothing to automate here beyond detecting that a change is needed.
minsky setup github-app --update reflects that: it reads the App's stored credentials, fetches
its current, live configuration via GET /app (a read, which works), diffs it against the
--events/--permissions you pass, and prints an actionable message — never a mutation.
# Show drift between the App's live config and the requested settings:
minsky setup github-app \
--name minsky-reviewer \
--update \
--events pull_request,issue_comment \
--permissions pull_requests:write,contents:read,metadata:readIf the live config already matches, the command reports "No changes." If it differs, the output
names the specific field(s) that differ, the App's exact settings URL
(https://github.com/settings/apps/<slug>/permissions), and the installation-acceptance step. Go
make the change there — see Manual: GitHub UI step 4 for the permission-level
meanings.
Update-mode flags:
--update— switch to drift-check mode. Reads stored credentials from<outputDir>/<name>.{pem,json}.--events <e1,e2,...>— event subscription list to compare against the live config.--permissions <k:v,...>— permissions map to compare against the live config.--name <name>— required. Identifies which stored credentials to use.--repois not required in update mode (the App already exists).--execute— accepted for backward compatibility; has no effect (there is no API call to gate behind it).
Detecting drift automatically: minsky config doctor also runs this comparison for you when
a GitHub App service account is configured (mt#3218) — a "GitHub App Permissions" diagnostic
reads the App's live permissions the same way and warns with the same settings-URL-plus-specific-permission
message if anything is missing, without you needing to invoke --update by hand.
After the script exits, skip to §4 (configure Minsky). Sections 2 and 3 are automated; section 1 steps below are only needed if you prefer the UI path.
-
Fill in the basic information:
- GitHub App name:
minsky(or a unique name for private deployments — app names are globally unique on github.com) - Homepage URL: your repository URL (e.g.,
https://github.com/you/yourrepo)
- GitHub App name:
-
Webhook: Uncheck "Active" unless you want GitHub to send events to a server. For local Minsky usage, no webhook is needed.
-
Set Repository permissions:
Permission Access level Pull requests Read & write Contents Read & write Metadata Read-only (auto-included) Why Contents is Read & write by default (mt#1477, mt#3210, mt#3218).
session_commit's git push injects the App installation token as an HTTP Authorization header so the push authenticates as the App (pushes authenticated as the App reliably triggerpull_requestworkflows; keychain-credentialed pushes may not). That push needs Contents: Read & write to succeed — withContents: Read-only, every App-token push is denied (403 "Permission ... denied to<app-slug>[bot]"), deterministically, regardless of token freshness. This was the manifest-flow's default forminsky setup github-appuntil mt#3218 (tracked as mt#3210's upstream cause); it is nowRead & writein both places.session_commitstill detects a denial and automatically retries via system keychain credentials as a safety net — so a misconfigured App degrades gracefully rather than surfacing a failed push — but you lose the App identity and the CI-trigger reliability mt#1477 exists for on that fallback path.Downgrading to Read-only. If this App only creates PRs and posts reviews via the REST API and never runs
session_commitagainst it (e.g. a pure review-only service account),Contents: Read-onlyis sufficient and reduces the App's blast radius. There is no API to change this after creation (see Checking for permission/event drift above) — decide at creation time, or change it later athttps://github.com/settings/apps/<slug>/permissions(the installing account must then accept the new permission set).Optional: CI rerun capability (mt#2775). The permission set above does not include Actions. If you want to use
forge_ci_run_rerun(the MCP/CLI tool that re-runs a GitHub Actions workflow run — seesrc/adapters/shared/commands/forge.ts), also grant Actions: Read and write under Repository permissions. This can only be set at creation time or via the settings UI (https://github.com/settings/apps/<slug>/permissions) — there is no update API. Without it, the tool returns a structured error naming the missing permission (403 "Resource not accessible by integration") rather than failing silently. -
Where can this GitHub App be installed?
- Choose "Only on this account" for personal use or a single-organization deployment.
- Choose "Any account" if you plan to offer this as a hosted service.
-
Click Create GitHub App.
-
On the App settings page that appears, note the App ID (shown near the top of the page).
-
Scroll to the Private keys section, click Generate a private key, and download the
.pemfile.
-
From the App settings page, click Install App in the left sidebar.
-
Choose the account (your personal account or an organization) where the target repository lives.
-
Select Only select repositories and choose the specific repositories you want the bot to access. Minimal scope is recommended.
-
Click Install.
-
After installation, look at the browser URL. For an installation on a personal account it will be:
https://github.com/settings/installations/<INSTALLATION-ID>Note the numeric Installation ID from the URL.
This form is verified against GitHub rather than assumed: the installation object returned by
GET /app/installations/{installation_id}carries anhtml_url, andbun scripts/verify-installation-settings-url.tsasserts that Minsky's constructed link equals it. For this project's installation it does (target_type: User).For an installation on an organization, the URL is different — GitHub configures an org installation under that organization's own settings (Settings > Third-party Access > GitHub Apps), and publishes no URL for either case. Take the ID from whatever URL your browser actually shows.
You do not need to tell Minsky which case you are in (mt#4764). The links
minsky setupemits are read from GitHub's ownhtml_urlon the installation object, so they are correct for a personal account and an organization alike. The constructedhttps://github.com/settings/installations/<ID>form above is only the fallback, used when that read is unavailable — and it is verified correct for the personal-account case (bun scripts/verify-installation-settings-url.ts, which compares it againsthtml_url).
Once github.serviceAccount.installationId is configured, minsky setup's App-coverage check
emits the settings page as a direct link when a repository is not covered, rather than telling
you to navigate there:
GitHub App: installation does NOT cover edobry/peezombie.me
Pull-request creation will fail with a 404 until this is granted.
Grant minsky-ai access at https://github.com/settings/installations/125403046 — pick edobry/peezombie.me under Repository access, then Save.
When no installation ID is configured, it falls back to the navigation path above rather than emitting a guessed URL. Both forms name the App slug, so a project with several configured App roles (implementer, reviewer) can tell the blocks apart.
Move the downloaded private key to a secure location and restrict its permissions:
mkdir -p ~/.config/minsky
mv ~/Downloads/<app-slug>.*.private-key.pem ~/.config/minsky/minsky-app.pem
chmod 600 ~/.config/minsky/minsky-app.pemNever commit the .pem file to version control.
You can supply the GitHub App credentials via a config file or environment variables. Environment variables take precedence.
If you used the automated path (scripts/create-github-app.ts): the script wrote credentials to ~/.config/minsky/<name>.pem (private key) and ~/.config/minsky/<name>.json (metadata including appId, installationId, and privateKeyFile). Paste the appId, installationId, and privateKeyFile values from the JSON into the examples below — they are filled in for you.
If you used the manual UI path: substitute the App ID, installation ID from section 2, and the private-key path you chose in section 3.
Add the serviceAccount block under github in ~/.config/minsky/config.yaml:
github:
token: <your-personal-access-token>
serviceAccount:
type: github-app
appId: <YOUR-APP-ID>
privateKeyFile: /Users/you/.config/minsky/<name>.pem # where <name> matches --name (e.g., minsky-ai, minsky-reviewer)
installationId: <YOUR-INSTALLATION-ID>token is your existing personal access token (unchanged). The serviceAccount block adds the bot identity on top of it.
export MINSKY_APP_ID=<YOUR-APP-ID>
export MINSKY_APP_PRIVATE_KEY_FILE=~/.config/minsky/<name>.pem # where <name> matches --name
export MINSKY_APP_INSTALLATION_ID=<YOUR-INSTALLATION-ID>Add these to your shell profile (.zshrc, .bashrc, etc.) to persist across sessions.
When using environment variables, the type: github-app discriminant is inferred automatically — you do not need to set a separate env var for it.
When running Minsky in a container or hosted environment (Railway, Docker, CI runners) there is no persistent filesystem to hold ~/.config/minsky/<name>.pem. Instead of staging the key into the image at build time (which leaks it into every layer), pass the PEM content directly via MINSKY_GITHUB_APP_PRIVATE_KEY:
# Preferred — the shell preserves real newlines end-to-end:
railway variables --set MINSKY_GITHUB_APP_PRIVATE_KEY="$(cat ~/.config/minsky/<name>.pem)"
railway variables --set MINSKY_APP_ID=<YOUR-APP-ID>
railway variables --set MINSKY_APP_INSTALLATION_ID=<YOUR-INSTALLATION-ID>Gotcha — Railway web UI flattens multi-line values. If you paste the PEM into Railway's dashboard, Railway stores it as a single line with literal \n escape sequences instead of real newlines. Minsky's GitHubAppTokenProvider auto-normalizes the \n-escaped form back to real newlines before signing, so both shapes work. The CLI $(cat ...) form above avoids the flattening entirely and is less error-prone.
Precedence. When both privateKey (inline) and privateKeyFile (path) are set, inline content wins. This lets you run the same image locally and in a container without reconfiguration — the container-only env var takes over when present, and your local file-path config is ignored.
Security note. The PEM value is never logged, never surfaced in error messages, and the process never writes it back to disk. Treat the env var as you would a private key file — scope it to the single service that needs it and rotate if it leaks.
See docs/deploy-minsky-railway.md for the full Railway deploy walkthrough that uses this env var.
Check that the service account fields appear in the resolved configuration:
minsky config show | grep -A4 serviceAccountExpected output:
serviceAccount:
type: github-app
appId: 123456
privateKeyFile: /Users/you/.config/minsky/minsky-app.pem
installationId: 78901234
Then verify that the token provider can authenticate and report the bot identity with the following script:
// verify-bot.ts
import { createTokenProvider } from "./src/domain/auth/index.ts";
import type { GitHubConfig } from "./src/domain/configuration/schemas/github.ts";
const config: GitHubConfig = {
serviceAccount: {
type: "github-app",
appId: Number(process.env.MINSKY_APP_ID),
privateKeyFile: process.env.MINSKY_APP_PRIVATE_KEY_FILE!,
installationId: Number(process.env.MINSKY_APP_INSTALLATION_ID),
},
};
const provider = createTokenProvider(config, process.env.GITHUB_TOKEN!);
const identity = await provider.getServiceIdentity();
console.log("Service identity:", identity);
// Expected: { login: "minsky[bot]", type: "app" }
const token = await provider.getServiceToken();
console.log(
"Installation token acquired:",
token.startsWith("ghs_") ? "yes (ghs_ prefix)" : token.slice(0, 10)
);Run with:
bun run verify-bot.tsWhen github.serviceAccount is present in the resolved configuration:
-
Factory selection:
createTokenProviderinstantiatesGitHubAppTokenProviderinstead ofFallbackTokenProvider. -
JWT generation: For each GitHub API call that needs a service token,
GitHubAppTokenProvider.generateJwt()creates a short-lived RS256 JWT signed with the private key. The JWT is issued 60 seconds in the past (to tolerate clock skew) and expires after 9 minutes. -
Installation token exchange: The JWT is sent to
POST /app/installations/{installationId}/access_tokens. GitHub returns a short-lived installation access token (ghs_...) valid for 1 hour. Optionally, a specific repository can be scoped by passing the repo name in the request body. -
Token caching: The installation token is cached in memory. Tokens are considered expired when fewer than 5 minutes remain, triggering a silent refresh before the next API call.
-
Routing: All GitHub API operations performed by Minsky's
RepositoryBackend(create PR, merge PR, post reviews) callTokenProvider.getServiceToken(), so they authenticate asminsky-ai[bot](or whatever slug you gave the App). -
Review submission: The
mcp__minsky__session_pr_review_submitMCP tool routes through this pipeline, so review comments appear as authored by the bot.
"Failed to fetch GitHub App info: 401"
The private key does not match the App ID, or the key has been revoked. Check:
- The
appIdin your config matches the App ID on the GitHub App settings page. - The
.pemfile is the one generated for this App (not a different App's key). - The key has not been deleted from the App settings page.
"Failed to create GitHub App installation token: 404" or "No installation found"
The App is not installed on the account that owns the target repository. Return to Install the App and verify the installation covers the correct account and repository.
Reviews still posting as the user, not the bot
Run minsky config show and verify serviceAccount appears in the output. If it is missing:
- Check for YAML syntax errors in
~/.config/minsky/config.yaml(indentation must be consistent). - If using env vars, verify
MINSKY_APP_ID,MINSKY_APP_INSTALLATION_ID, and one ofMINSKY_APP_PRIVATE_KEY_FILE(local) orMINSKY_GITHUB_APP_PRIVATE_KEY(hosted, inline PEM) are all exported (echo $MINSKY_APP_IDshould return a value). If neither key variable is set, Minsky raises"GitHub App private key is not configured: set MINSKY_GITHUB_APP_PRIVATE_KEY (env var) or github.serviceAccount.privateKeyFile (config file)".
"Installation token expired" or stale token errors
GitHubAppTokenProvider automatically refreshes tokens 5 minutes before the 1-hour expiry. If you still see expired token errors, check that your system clock is accurate — a clock skew of more than a few minutes can cause JWT validation failures on GitHub's side.
Private key file not found
The privateKeyFile path is expanded with ~/ support. Verify the path is correct and the file has not been moved. Check permissions with ls -la ~/.config/minsky/minsky-app.pem — it should show -rw-------.
GitHub App names are globally unique on github.com. Each deployment (individual developer, team, or hosted instance) needs its own GitHub App with a unique name.
To set up a private deployment:
- Each deployment operator creates their own App at https://github.com/settings/apps/new with a unique name (e.g.,
minsky-yourorg). - Configure the same permissions as listed in Create the GitHub App: pull requests (read & write), contents (read & write).
- Install the App on the repositories the deployment will manage.
- Provide the App ID, private key file path, and installation ID to Minsky via the config file or environment variables described in Configure Minsky.
For a future hosted Minsky service, a single central App would be registered once and installed on customer repositories via GitHub's standard App installation flow — customers would authorize the App through the GitHub UI, and Minsky would receive the installation ID as part of the onboarding process.