GitHub Apps skill for Codex
Least-privilege bots with short-lived tokens. Codex designs GitHub Apps from features to permissions, so installers see only what the bot needs. It authenticates per installation with Octokit-managed tokens, verifies X-Hub-Signature-256, answers webhooks within ten seconds and moves work to a queue, posts check runs in batches of 50 annotations, and adds a redelivery job because GitHub never retries failed deliveries on its own.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- github-apps Installs to ~/.agents/skills/github-apps/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install github-apps The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project github-apps Installs to .agents/skills/github-apps/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/github-apps.zipunzip -o github-apps.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) github-apps PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $github-apps, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds GitHub Apps - minimal permission and event selection, app JWTs and installation access tokens, webhook servers with X-Hub-Signature-256 verification, Octokit App and Probot handlers, the Checks API, installation lifecycle events, user access tokens, manifests and local webhook forwarding. Use when creating a GitHub bot or integration installed on orgs or repos, replacing a PAT-based bot with an App, posting check runs or PR comments from a service, debugging Bad credentials or Resource not accessible by integration, or handling installation events.
Use it when
- Building a bot or integration that orgs and users install
- Replacing a personal access token bot with scoped app credentials
- Posting check runs or PR comments from an external service
- Webhook deliveries fail, time out or trigger loops
Use something else when
- You just need a script that calls the API with your own token: use github-api
- The automation can run as a workflow inside the repo: use github-actions-automation
How to check the result
On a test repo the dev app handles each delivery ID once, rejects a request with a bad signature, updates records on installation_repositories events, and the redelivery job recovers a forced failure.
The mistake it prevents
Doing slow work inside the webhook handler, so deliveries exceed ten seconds, are marked failed and are never retried automatically.
Done well means: the app asks for the fewest permissions and events that its features need, authenticates per installation with short-lived tokens, verifies every webhook before acting, answers within 10 seconds and does real work asynchronously, and handles installs, uninstalls and repository selection changes without manual cleanup.
Before writing code
- Read
AGENTS.md; check for an existing app (app.yml, Probot config,octokitApp usage) and how it is hosted (long-running server, serverless, Actions). - Decide App versus alternatives:
| Need | Choose |
|---|---|
| Bot for many repos or orgs, acts as itself, scoped permissions | GitHub App |
| Act as the signed-in user in your product (their repos, their name) | GitHub App user access tokens (or an OAuth App if legacy) |
| A script you run for yourself | PAT and $github-api |
| Automation inside one repo on push or schedule | Actions with GITHUB_TOKEN |
- Secrets:
GITHUB_APP_ID(or client ID),GITHUB_APP_PRIVATE_KEY(PEM),GITHUB_WEBHOOK_SECRET. Never commit the private key; if it leaks, the user must generate a new key in the app settings and delete the old one. - Creating or changing the app registration, requesting new permissions (installers must re-approve), and any write to real repositories need explicit user confirmation. Develop against a test org or a personal test repository.
Webhook delivery and API calls need network access; local development needs a forwarding service (smee.io or a tunnel), which the user should approve.
Permissions and events
Start from features, not from a list of everything:
| Feature | Repository permission | Subscribe to |
|---|---|---|
| Comment on PRs | Pull requests: write (or Issues: write for issue comments) | pull_request |
| Post CI-style results | Checks: write | check_suite, pull_request |
| Read code to analyse | Contents: read | push |
| Label or triage issues | Issues: write | issues |
| Commit statuses only | Commit statuses: write | push or pull_request |
Metadata: read is always included. Avoid Administration and Contents: write unless the feature truly needs them; installers read the permission list before accepting.
Authentication model
private key --sign--> app JWT (RS256, iss = client ID or app ID, exp <= 10 min)
app JWT --POST /app/installations/{id}/access_tokens--> installation token (expires in 1 hour)
installation token --> REST/GraphQL calls as the app on that installation
- Set
iatabout 60 seconds in the past to tolerate clock drift. - Narrow tokens when possible: the access token request accepts
repositoriesandpermissionssubsets. - Let Octokit do this: it creates JWTs, caches installation tokens and refreshes them before expiry. Do not hand-roll the flow unless there is no maintained library for the language.
Webhook server with Octokit
import { App, createNodeMiddleware } from "octokit";
import http from "node:http";
const app = new App({
appId: process.env.GITHUB_APP_ID!,
privateKey: process.env.GITHUB_APP_PRIVATE_KEY!.replace(/\\n/g, "\n"), // env vars often hold escaped newlines
webhooks: { secret: process.env.GITHUB_WEBHOOK_SECRET! },
});
app.webhooks.on("pull_request.opened", async ({ octokit, payload }) => {
// octokit is already authenticated as this installation
await enqueue({ installationId: payload.installation!.id, repo: payload.repository.full_name, pr: payload.pull_request.number });
});
app.webhooks.on("installation.deleted", async ({ payload }) => {
await db.installations.markRemoved(payload.installation.id);
});
app.webhooks.on("installation_repositories", async ({ payload }) => {
await db.repos.sync(payload.installation.id, payload.repositories_added, payload.repositories_removed);
});
app.webhooks.onError((err) => console.error("webhook failed", err));
// Verifies X-Hub-Signature-256 before dispatching; default path /api/github/webhooks
http.createServer(createNodeMiddleware(app)).listen(3000);
Worker that does the slow part:
async function reviewPullRequest(job: { installationId: number; repo: string; pr: number }) {
const octokit = await app.getInstallationOctokit(job.installationId);
const [owner, repo] = job.repo.split("/");
const { data: pr } = await octokit.rest.pulls.get({ owner, repo, pull_number: job.pr });
const run = await octokit.rest.checks.create({
owner, repo, name: "acme-lint", head_sha: pr.head.sha, status: "in_progress",
});
const findings = await lint(octokit, owner, repo, pr);
await octokit.rest.checks.update({
owner, repo, check_run_id: run.data.id, status: "completed",
conclusion: findings.length ? "failure" : "success",
output: { title: `${findings.length} issues`, summary: summarize(findings), annotations: findings.slice(0, 50) },
});
}
The Checks API only accepts up to 50 annotations per request; send more with additional
checks.update calls. Check runs are only writable by GitHub Apps, not PATs.
Probot wraps the same model (app.on("pull_request.opened", handler), context.octokit) and is
a reasonable choice when the project already uses it.
Webhook rules
- Respond with 2xx within 10 seconds; GitHub marks slower deliveries as failed.
- GitHub does not automatically redeliver failed deliveries. Build a recovery job that lists
failed deliveries (
GET /app/hook/deliveries) and redelivers them (POST /app/hook/deliveries/{delivery_id}/attempts), or reconcile from the API. - Dedupe on
X-GitHub-Delivery. Route onX-GitHub-Eventpluspayload.action. - Ignore events from your own app (
payload.sender.type === "Bot"and your app’s slug) to avoid loops when your comments trigger new events.
User access tokens
When the app must act as a user, use the web flow with the app’s client ID and secret. User access tokens expire after 8 hours and come with a refresh token valid for 6 months (when token expiration is enabled). The effective permissions are the intersection of the app’s permissions and what the user can do.
Local development
npx smee-client --url https://smee.io/<channel> --target http://localhost:3000/api/github/webhooks
Point a development app (not the production app) at the smee channel. Replay a delivery from the app’s Advanced settings page to retest a handler.
Rate limits
Installation tokens get at least 5,000 requests per hour, scaling with repositories and users
up to 12,500 (higher on Enterprise Cloud). All work for one installation shares that budget, so
cache reads and use conditional requests. Secondary limits apply as in $github-api.
Gotchas
- “Resource not accessible by integration”: the app lacks a permission, the repo is not in the installation’s selection, or installers have not accepted an updated permission request.
- “Bad credentials” or JWT errors: wrong key, private key newlines mangled,
expmore than 10 minutes ahead, or server clock skew. - Using the app JWT for repository calls: JWTs only work on
/appendpoints; get an installation token first. - Hard-coding an installation ID: each org or user that installs the app has its own.
- Long work inside the webhook handler: deliveries time out and are not retried.
- Pull requests from forks: the head repo may not be in the installation; read code by SHA from the base repository.
Verification
- Install the dev app on a test repo, open a PR, and see the handler run once per delivery ID.
- Send a request with a wrong signature to the endpoint: it returns 400 or 401 and does nothing.
- Remove a repo from the installation’s selection and confirm
installation_repositoriesupdates your records. - Run the failed-delivery recovery job against a delivery you forced to fail.
Report template
**App:** acme-lint (dev app: acme-lint-dev), hosted on Fly.io, Octokit App 4.x
**Permissions:** Checks write, Pull requests read, Contents read, Metadata read
**Events:** pull_request, installation, installation_repositories
**Flow:** webhook -> queue -> worker posts check run with up to 50 annotations per call
**Reliability:** dedupe on X-GitHub-Delivery; hourly redelivery job for failed deliveries
**Needs user:** production app registration changes, permission upgrade notice to installers What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "GitHub Apps"
short_description: "Least-privilege bots with short-lived tokens"
default_prompt: "$github-apps Turn our PAT-based PR bot into a GitHub App with the minimum permissions" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.