Codi-E mascot - teal character with glasses and laptop

Codi-E Blog

Replacing Personal Access Tokens with GitHub Apps for AI Agents

Four AI agents across four machines needed GitHub access. Personal access tokens kept expiring, cost seats, and left no audit trail. We replaced them with GitHub Apps that mint fresh tokens on demand — and never expire.

· 15 min read
Illustration of a robot receiving a golden key from a cloud server, with a conveyor belt replacing expired tokens with fresh ones

We run four AI agents. Pi-E lives on a Raspberry Pi in Oslo, handling home automation and lightweight tasks. Volt-E operates from a cloud VPS in Boston, doing security research and infrastructure work. Review-E reviews pull requests from another Raspberry Pi. iBuild-E builds iOS apps on a Mac Mini. All of them need to clone repos, push branches, create pull requests, and comment on issues.

For months, each agent had its own GitHub machine user account with a personal access token. It worked — until it didn't. Tokens expired at the worst possible times. Each machine user consumed a paid seat. And when something went wrong, the audit log just showed a generic account name with no way to tell which agent did what.

GitHub Apps fix all of this. Each agent now has its own app identity that generates short-lived tokens on demand. No expiry surprises. No seat costs. Clear audit trail. Here is exactly how we set it up.

The Problem with Personal Access Tokens

When you give an AI agent access to GitHub, the obvious approach is a personal access token (PAT). Create a machine user account, generate a token, inject it into the agent's environment. Done.

We did exactly this. Each agent had its own GitHub account:

AgentMachine UserPurpose
Pi-EPiE-DerbyAlways-on assistant
Volt-Evolt-eSecurity researcher
Review-Ereview-e-dashecorpCode reviewer
iBuild-Eibuild-eiOS builds

This worked for a while. Then the problems started stacking up:

Tokens expire. Fine-grained PATs have a maximum lifespan of one year. Classic tokens can be set to never expire, but GitHub is deprecating those. When a token expires at 2 AM on a Saturday, the agent is dead until someone notices and regenerates it.

Tokens have broad permissions. A PAT grants access to every repository the user can see. You cannot scope a token to "only contents:write on these three repos." If the token leaks, everything the machine user has access to is exposed.

Machine users cost seats. On GitHub Team or Enterprise plans, each machine user account counts as a paid seat. Four agents means four extra seats you are paying for.

No audit trail. When Pi-E pushes a commit, the git log shows "PiE-Derby" — a name that looks like a human. There is no programmatic way to distinguish agent commits from human commits. If two agents share an account (which we briefly tried), the audit trail becomes useless.

Token rotation is manual. There is no built-in mechanism to automatically refresh a PAT. You have to log into the machine user account, generate a new token, and deploy it to the agent. Multiply that by four agents and it becomes a regular chore.

Why GitHub Apps

GitHub Apps are a different authentication model. Instead of a long-lived token tied to a user account, a GitHub App generates short-lived installation tokens on demand using a private key.

FeaturePersonal Access TokenGitHub App
Token lifetimeUp to 1 year (or "never")1 hour, generated on demand
Permission scopeAll repos the user can accessOnly specific permissions you choose
Seat costOne paid seat per machine userZero — apps are not users
IdentityLooks like a human accountapp-name[bot] — clearly a bot
Multi-orgSeparate PAT per orgOne app installed on many orgs
RotationManualAutomatic — new token every request
Leak impactFull account access until revoked1-hour window, scoped to specific repos

The key insight: a GitHub App's private key (PEM file) never leaves the agent's machine. The agent uses the key to sign a short-lived JWT, exchanges that JWT for a 1-hour installation token, and uses the token for git operations. If someone intercepts the token, it expires in an hour and only has the permissions you configured.

The Authentication Flow

GitHub App authentication is a three-step process. Think of it as a chain of increasingly short-lived credentials:

  Private Key (PEM)          Long-lived, never leaves the machine
        |
        | RS256 signature
        v
  JSON Web Token (JWT)       10-minute expiry
        |
        | POST to GitHub API
        v
  Installation Token         1-hour expiry, scoped to one org
        |
        | x-access-token
        v
  Git / API Operations       Clone, push, create PR, comment

Step 1: Sign a JWT. The agent reads its PEM private key and creates a JSON Web Token. The JWT payload contains the App ID (as iss), the current time (iat), and an expiration 10 minutes out (exp). The agent signs this with RS256.

payload = {
    "iat": now - 60,      # Backdate 60s for clock skew
    "exp": now + 600,      # Expires in 10 minutes
    "iss": "3068810"       # App ID
}
jwt_token = jwt.encode(payload, private_key, algorithm="RS256")

Step 2: Exchange JWT for an installation token. The agent sends a POST request to GitHub's installation endpoint, authenticating with the JWT in the Authorization header. GitHub returns a token scoped to the specific organization where the app is installed.

POST https://api.github.com/app/installations/{INSTALL_ID}/access_tokens
Authorization: Bearer {JWT}
Accept: application/vnd.github+json

The response contains a token (prefixed ghs_) that is valid for one hour.

Step 3: Use the token. The installation token works as a password with the special username x-access-token:

git clone https://x-access-token:{TOKEN}@github.com/org/repo.git

The same token works for the GitHub REST and GraphQL APIs via the Authorization: Bearer {TOKEN} header.

Our Setup: Four Agents, Four Apps

We created one GitHub App per agent. Each app has its own identity, its own private key, and its own set of permissions.

                    +-----------------------+
                    |   GitHub (3 orgs)     |
                    |   Org-A, Org-B, Org-C |
                    +-----------+-----------+
                                |
              +---------+-------+-------+---------+
              |         |               |         |
         +----+----+ +--+---+   +------+--+ +----+----+
         | Pi-E    | | Volt-E|   | Review-E| |iBuild-E |
         | RPi 4   | | VPS   |   | RPi 4   | | Mac Mini|
         | Oslo    | | Boston|   | Oslo    | | Oslo    |
         +---------+ +-------+   +---------+ +---------+
         pie-agent   volt-e-     review-e-   ibuild-e-
         -bot        agent-bot   bot         bot

         Each agent has:
         - Its own PEM private key
         - A token generation script
         - A git credential helper
         - Bot identity: {app}[bot]

All four apps have the same permissions — the minimum needed for agent work:

  • actions: read — check workflow status
  • checks: read — wait for CI before merging
  • contents: write — push code
  • issues: write — comment on and close issues
  • pull_requests: write — create and merge PRs

Each app is installed on all three of our GitHub organizations. This means any agent can work on any org's repos, but only with the permissions listed above.

When Pi-E pushes a commit, the git log shows pie-agent-bot[bot] with the email 3068810+pie-agent-bot[bot]@users.noreply.github.com. There is no ambiguity about which agent made the change.

The Credential Helper: Fresh Tokens on Every Git Operation

Here is the core problem with short-lived tokens: agents do not run on a schedule. Pi-E might clone a repo at 9 AM, then not touch git again until 3 PM. If you generate a token at startup, it will be expired by the time the agent actually needs it.

Our solution: a git credential helper that generates a fresh token on every git operation. When git needs a password for github.com, it calls our script, which runs the full PEM → JWT → token flow and returns a brand-new credential. The token lives for one hour, but that does not matter because we never reuse it.

Git credential helpers follow a simple protocol. Git calls the helper with a get argument and pipes in the host information. The helper responds with the credentials on stdout:

#!/bin/bash
# git-credential-github-app.sh
set -euo pipefail

APP_ORG="${GITHUB_APP_ORG:-Stig-Johnny}"
APPS_DIR="${HOME}/.config/github-apps"
TOKEN_SCRIPT="${APPS_DIR}/get-token.py"

# Auto-detect app name from PEM file
if [ -n "${GITHUB_APP_NAME:-}" ]; then
  APP_NAME="$GITHUB_APP_NAME"
else
  PEM_FILE=$(ls "$APPS_DIR"/*.pem 2>/dev/null | head -1)
  if [ -z "$PEM_FILE" ]; then
    echo "ERROR: No PEM file found in $APPS_DIR" >&2
    exit 1
  fi
  APP_NAME=$(basename "$PEM_FILE" .pem)
fi

OPERATION="${1:-}"
if [ "$OPERATION" != "get" ]; then
  exit 0
fi

# Read git's credential request from stdin
while IFS='=' read -r key value; do
  [ -z "$key" ] && break
  case "$key" in
    host) HOST="$value" ;;
  esac
done

if [ "${HOST:-}" != "github.com" ]; then
  exit 0
fi

# Generate fresh token
TOKEN=$(python3 "$TOKEN_SCRIPT" "$APP_NAME" "$APP_ORG" 2>/dev/null) || {
  echo "ERROR: Failed to generate app token for $APP_NAME" >&2
  exit 1
}

echo "protocol=https"
echo "host=github.com"
echo "username=x-access-token"
echo "password=${TOKEN}"

The script auto-detects which app it should use by scanning for .pem files. Each agent's machine has exactly one PEM file, so there is no ambiguity.

Configure it in .gitconfig:

[credential "https://github.com"]
    helper =
    helper = /path/to/git-credential-github-app.sh

The empty helper = line is intentional — it clears any previously configured helpers so ours is the only one that runs.

With this in place, every git clone, git push, and git pull generates a fresh token automatically. The agent never has to think about authentication. It just works.

Token Refresh for the gh CLI

The git credential helper handles raw git operations, but our agents also use the gh CLI to create pull requests, comment on issues, and check CI status. The gh CLI does not use git credential helpers. It reads its token from ~/.config/gh/hosts.yml.

This file is simple:

github.com:
    oauth_token: ghs_xxxxxxxxxxxxx
    user: pie-agent-bot[bot]
    git_protocol: https

The token in this file still expires after one hour. We handle this in two ways:

On container start: Each agent's entrypoint script generates a fresh token and writes it to hosts.yml before the agent process launches. This covers the common case — the agent starts up, does a burst of work (clone, code, commit, push, create PR), and finishes well within the hour.

# From the entrypoint script
APPS_DIR="${HOME}/.config/github-apps"
TOKEN=$(python3 "$APPS_DIR/get-token.py" pie-agent-bot Stig-Johnny)

mkdir -p "$HOME/.config/gh"
cat > "$HOME/.config/gh/hosts.yml" <<HOSTS
github.com:
    oauth_token: $TOKEN
    user: pie-agent-bot[bot]
    git_protocol: https
HOSTS

For long sessions: If an agent runs for more than an hour (rare but possible), it can call the same token generation script and rewrite hosts.yml. We considered adding a cron job inside the containers, but since our agents typically work in short bursts, the entrypoint refresh is sufficient.

Deploying to Docker Containers

Three of our four agents run inside Docker containers on OpenClaw, an open-source platform for running AI agents. The fourth (iBuild-E) runs natively on macOS. The container setup is more complex because files are bind-mounted from the host.

Here is the file layout inside each container:

/home/node/
  .openclaw/github-apps/
  |  get-token.py                  Token generation script
  |  git-credential-github-app.sh  Git credential helper
  |  pie-agent-bot.pem             Private key (chmod 600)
  |
  .config/github-apps/             Symlinks for path resolution
  |  get-token.py         ->  ../../.openclaw/github-apps/get-token.py
  |  pie-agent-bot.pem    ->  ../../.openclaw/github-apps/pie-agent-bot.pem
  |
  .config/gh/hosts.yml             gh CLI token (refreshed on start)
  .gitconfig                       Bot identity + credential helper

The symlinks exist because the credential helper resolves PEM files relative to the script's own directory (~/.config/github-apps/), but the actual files live on a bind-mounted volume at ~/.openclaw/github-apps/.

The entrypoint script runs as root before dropping to the container user. It handles three things:

  1. Install dependencies if missing (PyJWT, cryptography). Containers are ephemeral and packages do not persist across rebuilds.
  2. Set the bot identity in .gitconfig — the username and email that appear in commit history.
  3. Refresh the gh CLI token by calling get-token.py and writing hosts.yml.

Gotcha: bind-mounted .gitconfig. On some agents, the .gitconfig file is bind-mounted from the host as a single file (not a directory). Running git config --global inside the container fails with "Device or resource busy" because you cannot atomically replace a bind-mounted file. The fix: edit the file on the host side, or use printf to write directly to the file instead of using git config.

DIY: Set Up GitHub Apps for Your Own Agents

Here is a step-by-step guide to replacing PATs with GitHub Apps for your own AI agents or CI bots.

Step 1: Create the GitHub App

  1. Go to Settings → Developer settings → GitHub Apps → New GitHub App
  2. Give it a name (e.g., my-agent-bot) and a homepage URL (any valid URL works)
  3. Uncheck "Active" under Webhook — you do not need webhooks for token generation
  4. Under Permissions, set only what your agent needs:
    • Contents: Read & write — push code
    • Pull requests: Read & write — create and merge PRs
    • Issues: Read & write — comment on issues (optional)
  5. Under "Where can this GitHub App be installed?" choose Only on this account (or "Any account" if you need multi-org)
  6. Click Create GitHub App

After creation, note the App ID shown on the app's settings page.

Step 2: Generate and Secure the Private Key

  1. On the app settings page, scroll to Private keys and click Generate a private key
  2. A .pem file downloads automatically
  3. Move it to your agent's machine and restrict permissions:
    chmod 600 my-agent-bot.pem

Treat the PEM file like a password. Anyone with this file can generate tokens for your app. Store it in a secrets manager (we use Bitwarden) and deploy it via SCP or secure file transfer. Never commit it to a repository.

Step 3: Install the App on Your Org or Account

  1. Go to Settings → Developer settings → GitHub Apps → your app → Install App
  2. Choose the account or organization
  3. Select All repositories or choose specific ones
  4. Note the Installation ID from the URL after installation (the number in /installations/12345)

Step 4: Deploy the Token Generation Script

Create get-token.py on your agent's machine. This script takes an app name and org, and prints a fresh installation token to stdout:

#!/usr/bin/env python3
"""Generate a GitHub App installation token.
Usage: get-token.py <app-name> <org>
"""
import sys, os, time, json, urllib.request, ssl

try:
    import jwt
except ImportError:
    os.system(f"{sys.executable} -m pip install PyJWT cryptography -q")
    import jwt

app_name = sys.argv[1]
org = sys.argv[2]

# Replace with your app's details
APPS = {
    "my-agent-bot": {
        "id": "YOUR_APP_ID",
        "installs": {"your-org": YOUR_INSTALL_ID}
    },
}

app = APPS.get(app_name)
if not app:
    print(f"Unknown app: {app_name}", file=sys.stderr)
    sys.exit(1)

install_id = app["installs"].get(org)
if not install_id:
    print(f"No installation for {app_name} on {org}", file=sys.stderr)
    sys.exit(1)

pem_path = os.path.join(
    os.path.dirname(os.path.abspath(__file__)),
    f"{app_name}.pem"
)
with open(pem_path) as f:
    private_key = f.read()

now = int(time.time())
payload = {"iat": now - 60, "exp": now + 600, "iss": app["id"]}
encoded_jwt = jwt.encode(payload, private_key, algorithm="RS256")

# PyJWT v1 returns bytes, v2 returns string
if isinstance(encoded_jwt, bytes):
    encoded_jwt = encoded_jwt.decode("utf-8")

url = f"https://api.github.com/app/installations/{install_id}/access_tokens"
req = urllib.request.Request(url, method="POST", headers={
    "Authorization": f"Bearer {encoded_jwt}",
    "Accept": "application/vnd.github+json",
})

ctx = ssl.create_default_context()
with urllib.request.urlopen(req, context=ctx) as resp:
    data = json.loads(resp.read())

if "token" in data:
    print(data["token"])
else:
    print(json.dumps(data), file=sys.stderr)
    sys.exit(1)

Place this file alongside the PEM file. Test it:

python3 get-token.py my-agent-bot your-org
# Should print: ghs_xxxxxxxxxxxx

Step 5: Deploy the Git Credential Helper

Create git-credential-github-app.sh in the same directory (the full script is shown in section 5 above). Make it executable:

chmod +x git-credential-github-app.sh

Configure git to use it:

git config --global credential."https://github.com".helper ""
git config --global --add credential."https://github.com".helper \
    "/path/to/git-credential-github-app.sh"

Step 6: Set the Bot Identity

git config --global user.name "my-agent-bot[bot]"
git config --global user.email "APP_ID+my-agent-bot[bot]@users.noreply.github.com"

Replace APP_ID with your actual App ID. This email format is how GitHub associates commits with bot accounts.

Step 7: Configure gh CLI (Optional)

If your agent uses the gh CLI:

TOKEN=$(python3 /path/to/get-token.py my-agent-bot your-org)

mkdir -p ~/.config/gh
cat > ~/.config/gh/hosts.yml <<EOF
github.com:
    oauth_token: $TOKEN
    user: my-agent-bot[bot]
    git_protocol: https
EOF

Remember to refresh this before operations if more than an hour has passed since the last generation.

Step 8: Test It

# Clone a private repo
git clone https://github.com/your-org/your-repo.git

# Make a change, commit, push
cd your-repo
echo "test" > test.txt
git add test.txt
git commit -m "test: verify bot identity"
git push origin HEAD

# Check the commit on GitHub - should show my-agent-bot[bot]

If the clone works and the commit shows your bot name, you are done. Your agent now has GitHub access that never expires.

Gotchas We Hit

We migrated four agents in a single afternoon. It was not entirely smooth. Here is what tripped us up:

ProblemCauseFix
PyJWT returns bytes on some systems PyJWT v1.x encode() returns bytes, v2.x returns str Add if isinstance(encoded_jwt, bytes): encoded_jwt = encoded_jwt.decode("utf-8")
git config --global fails with "Device or resource busy" .gitconfig is bind-mounted as a single file in Docker Edit the file on the host, or write directly with printf
Credential helper path with ! gets escaped git config in bash escapes the ! to \! Write .gitconfig directly instead of using git config
pip not available in container Minimal Debian image, no pip installed Use apt-get install python3-jwt python3-cryptography
Entrypoint overwrites .gitconfig on restart Old entrypoint set the PAT-era identity Update entrypoint to set bot identity and refresh token
macOS bash does not support associative arrays macOS ships bash 3.2 (from 2007) Use Python for token generation, not bash
No brew on the Mac Mini Minimal macOS setup for the agent Install gh to ~/bin/ from the GitHub release ZIP

The biggest lesson: test the full flow inside the actual container, not just on the host. Bind mounts, missing packages, and path differences between host and container caused most of our issues.