Skip to content
By Barak Korren, Adam Scerra, Greg Allen, Hector Martinez, Wayne Sun
Picture of Barak Korren
Barak Korren
Picture of Adam Scerra
Adam Scerra
Picture of Greg Allen
Greg Allen
Picture of Hector Martinez
Hector Martinez
Picture of Wayne Sun
Wayne Sun

Standalone mint

This guide covers setting up and running the standalone token mint — a lightweight, self-hosted alternative to the GCP-hosted mint Cloud Function. The standalone mint lets you run your own OIDC token exchange service without any GCP infrastructure, with the option to proxy unhandled roles to the hosted mint.

This guide is for users who want to run agents with their own GitHub App identity. If you are using the hosted mint with the shared fullsend apps, see Mint service administration instead — you do not need to run your own mint.

Why use the standalone mint?

The hosted fullsend mint uses shared GitHub Apps owned by the fullsend team. Every organization enrolled in the hosted mint shares the same apps — your agents authenticate as fullsend-ai-triage, fullsend-ai-coder, etc. This works for most use cases, but there are reasons to run your own mint:

  • Own your agent identity. Your agents authenticate as GitHub Apps you created and control. Commits, PR reviews, and issue comments come from your app, not a shared one. This matters for compliance, auditing, and brand identity.

  • Custom agent roles. The hosted mint supports a fixed set of roles (triage, coder, review, fix, retro, prioritize, fullsend). The standalone mint lets you define custom roles with permissions tailored to your workflows — a scanner role with security_events:write, a deployer role with deployments:write, or anything else the GitHub API supports.

  • No GCP dependency. The standalone mint is a single binary that reads PEM keys from a local directory and validates OIDC tokens directly against GitHub's JWKS endpoint. No GCP project, Secret Manager, Cloud Functions, or WIF configuration required.

  • Gradual adoption. The fallback proxy lets you serve some roles locally while proxying the rest to the hosted mint. You can start with one custom role and expand over time without disrupting existing workflows.

How it works

The standalone mint is an HTTP server that:

  1. Receives token requests from GitHub Actions workflows (same protocol as the hosted mint)
  2. Validates the caller's OIDC token directly against GitHub's JWKS endpoint
  3. Uses a local PEM key to authenticate as your GitHub App
  4. Returns a scoped installation token for the requested repos

For roles without a local PEM, the optional fallback proxy forwards the request to an upstream mint (typically the hosted one), so existing workflows keep working.

Prerequisites

  • Go 1.26+ to build the binary (or use a pre-built release)
  • A GitHub organization where you will install your custom GitHub Apps
  • The hosted mint URL (optional, for fallback proxy): https://mint.fullsend.sh
  • Shared public GitHub Apps installed (optional, for fallback proxy) — see Getting Started

Step 1: Create a GitHub App

Each role in the standalone mint corresponds to a GitHub App. Create one for each custom role you want to serve.

  1. Go to https://github.com/organizations/YOUR-ORG/settings/apps/new

  2. Fill in the app details:

    • Name: Choose something descriptive (e.g., myorg-scanner, myorg-deployer)
    • Homepage URL: Any URL (e.g., your org's GitHub page)
    • Webhook: Uncheck "Active" — the mint does not use webhooks
  3. Set the permissions your role needs. For example, a scanner role might need:

    • Repository permissions:
      • Contents: Read
      • Security events: Write
      • Metadata: Read (always required)
  4. Click Create GitHub App

  5. Note the App ID shown on the app's settings page (a numeric ID like 12345678)

  6. Generate a private key:

    • Scroll to Private keys on the app settings page
    • Click Generate a private key
    • Save the downloaded .pem file — this is the only time GitHub provides it
  7. Install the app on your organization:

Repeat for each custom role.

Step 2: Set up PEM storage

Create a directory to hold your PEM keys. Each file must be named {role}.pem:

bash
mkdir -p pems/

# Copy your downloaded PEM files, naming them by role:
cp ~/Downloads/myorg-scanner.2026-06-18.private-key.pem pems/scanner.pem
cp ~/Downloads/myorg-deployer.2026-06-18.private-key.pem pems/deployer.pem

If you also serve built-in roles locally (e.g., triage), add those PEMs too:

bash
cp ~/Downloads/myorg-triage.2026-06-18.private-key.pem pems/triage.pem

Keep PEM files secure. Anyone with a PEM key can authenticate as the corresponding GitHub App. Set restrictive file permissions (chmod 600 pems/*.pem) and do not commit PEM files to version control.

Step 3: Build the standalone mint

bash
git clone https://github.com/fullsend-ai/fullsend.git
cd fullsend/cmd/mint
go build -o fullsend-mint .

Step 4: Configure and run

The standalone mint is configured entirely through environment variables:

Required variables

VariableDescriptionExample
ALLOWED_ORGSComma-separated GitHub orgs allowed to request tokens, or * for public mint mode (any org; upstream-only workflow provenance)myorg,myorg-sandbox or *
ROLE_APP_IDSJSON map of role name to GitHub App ID (use plain role names, not org-prefixed){"triage":"4087047","scanner":"5555555"}
PEM_DIRPath to directory containing {role}.pem files./pems

Optional variables

VariableDescriptionExample
ALLOWED_WORKFLOW_FILESComma-separated workflow file allowlist; * for all*
FALLBACK_MINT_URLUpstream mint URL for roles without local PEMshttps://mint.fullsend.sh
CUSTOM_ROLE_PERMISSIONSJSON map of custom role permissions (see below){"scanner":{"contents":"read"}}
PER_REPO_WIF_REPOSComma-separated repos with per-repo WIF treatment. Use * for public mint mode (all repos get per-repo treatment). Per-repo callers can only mint to their own repo scope. Callers not in this list fall through to per-org (ALLOWED_ORGS) and get org-mode repos shapes.myorg/private-repo
WORKFLOW_HOST_REPOSComma-separated repos whose workflows are trusted to call the mint for per-repo callers. Per-org callers are not affected (they hard-wire to {org}/.fullsend and upstream). Defaults to fullsend-ai/fullsend when unset.fullsend-ai/fullsend,myorg/my-workflows
PORTHTTP listen port8080 (default)

Public mint mode

Set ALLOWED_ORGS=* and PER_REPO_WIF_REPOS=* to enable public mint mode:

  • Any org may request tokens (installation lookup still scopes tokens to the requesting org)
  • job_workflow_ref validation uses the same per-repo path: only repos in WORKFLOW_HOST_REPOS (defaults to fullsend-ai/fullsend) are accepted, and the ALLOWED_WORKFLOW_FILES basename gate applies (ADR 0082 §2)
  • Set PER_REPO_WIF_REPOS=* alongside ALLOWED_ORGS=*
  • No WIF or GCP STS setup is required — standalone mint validates OIDC via GitHub JWKS directly

Example: local roles with fallback proxy

This configuration serves triage and scanner locally while proxying all other roles (coder, review, etc.) to the hosted mint:

bash
export ALLOWED_ORGS="myorg"
export ROLE_APP_IDS='{"triage":"4087047","scanner":"5555555"}'
export PEM_DIR="./pems"
export ALLOWED_WORKFLOW_FILES="*"
export FALLBACK_MINT_URL="https://mint.fullsend.sh"
export CUSTOM_ROLE_PERMISSIONS='{"scanner":{"contents":"read","security_events":"write","metadata":"read"}}'

./fullsend-mint

On startup, the mint logs the configuration:

2026/06/18 12:00:00 custom role permissions registered: [scanner]
2026/06/18 12:00:00 fallback mint configured: https://mint.fullsend.sh (local roles: [scanner triage])
2026/06/18 12:00:00 fullsend-mint starting on :8080 (standalone mode)

Example: standalone only (no fallback)

If you do not need the hosted mint at all, omit FALLBACK_MINT_URL. Requests for roles without local PEMs will be rejected:

bash
export ALLOWED_ORGS="myorg"
export ROLE_APP_IDS='{"triage":"4087047","scanner":"5555555"}'
export PEM_DIR="./pems"
export ALLOWED_WORKFLOW_FILES="*"
export CUSTOM_ROLE_PERMISSIONS='{"scanner":{"contents":"read","security_events":"write","metadata":"read"}}'

./fullsend-mint

Step 5: Expose the mint to GitHub Actions

GitHub Actions workflows need to reach your mint over HTTPS. Options include:

  • Cloudflare Tunnel (quick for testing):

    bash
    cloudflared tunnel --url http://localhost:8080
  • Reverse proxy (nginx, Caddy) with a TLS certificate

  • Cloud VM with a public IP and Let's Encrypt

Once you have a public URL, set it as a GitHub Actions variable so workflows use your mint:

bash
# Set at the org level (applies to all repos)
gh api -X POST /orgs/myorg/actions/variables \
  -f name=FULLSEND_MINT_URL \
  -f value="https://your-mint-url.example.com" \
  -f visibility=all

# Or set per-repo
gh api -X POST /repos/myorg/my-repo/actions/variables \
  -f name=FULLSEND_MINT_URL \
  -f value="https://your-mint-url.example.com"

Note: Repository-level variables override organization-level variables in GitHub Actions. If a repo already has FULLSEND_MINT_URL set at the repo level, update it there — the org-level variable will be ignored for that repo.

Privilege levels

Levels are keys on each role. The mint looks up the requested level and returns the stored permission map — or returns an error if the level is not defined. Every role must define at least read and write; custom roles may define additional named levels.

LevelBehavior
"read"Returns the read-level permission map (built-in roles: all values "read")
"write"Returns the write-level permission map (built-in roles: the canonical ceiling)
(omitted)Defaults to "write" (temporary compatibility default — a future release will change to "read")
(custom)Custom roles may define extra named levels; the mint looks them up the same way

Level names must match ^[a-z][a-z0-9_-]{0,31}$ (starts with a lowercase letter, max 32 characters). Invalid names are rejected with HTTP 400.

Temporary compatibility default: Omitting level currently defaults to "write" so existing HTTP clients keep receiving write-level tokens. A future PR will migrate the default to "read". Callers that need read-only tokens should pass "level": "read" explicitly.

Custom role permissions

Defining permissions

Custom roles require an explicit permissions map via the CUSTOM_ROLE_PERMISSIONS environment variable. This tells the mint what permissions to request when creating installation tokens for the role.

Flat format

The simplest format is a JSON object mapping role names to permission maps. The given map is stored as both the read and write levels — requesting either level returns the same permissions:

json
{
  "scanner": {
    "contents": "read",
    "security_events": "write",
    "metadata": "read"
  },
  "deployer": {
    "contents": "read",
    "deployments": "write",
    "environments": "write",
    "metadata": "read"
  }
}

Multi-level format

To define distinct read and write privilege levels for a custom role, wrap the permission maps inside a levels key:

json
{
  "deployer": {
    "levels": {
      "read": {
        "contents": "read",
        "deployments": "read",
        "metadata": "read"
      },
      "write": {
        "contents": "read",
        "deployments": "write",
        "environments": "write",
        "metadata": "read"
      }
    }
  }
}

When a token request omits the level field (or sets it to "read"), the mint returns the read-level permissions. Setting level: "write" returns the write-level permissions. Multi-level roles must define both read and write levels; extra named levels are allowed. The mint looks up the requested level and fails if it is not defined — there is no derivation or fallback.

Both formats can be mixed in a single CUSTOM_ROLE_PERMISSIONS value — some roles flat, others multi-level. The mint auto-detects the format per role by checking for the levels key.

Permission names and levels match the GitHub App permissions API. Supported levels include read, write, and admin where GitHub exposes that level.

Built-in roles cannot be overridden

The following role names are reserved and cannot be used in CUSTOM_ROLE_PERMISSIONS:

  • triage, coder, review, fix, retro, prioritize, fullsend, e2e, scribe

Attempting to define a custom role with a built-in name will cause the mint to fail at startup:

registering custom role permissions: custom role "triage" collides with built-in role

This prevents accidental changes to the permission downscoping of built-in roles. If you want to serve a built-in role with your own GitHub App (using the standard permissions for that role), add it to ROLE_APP_IDS and provide its PEM — no entry in CUSTOM_ROLE_PERMISSIONS is needed.

Role naming rules

Custom role names must:

  • Start with a lowercase letter
  • Contain only lowercase letters, digits, hyphens, and underscores
  • Not contain double hyphens (--)

Valid examples: scanner, deploy-prod, code_review, my-agent-v2

Invalid examples: Scanner (uppercase), 123scanner (starts with digit), my--agent (double hyphen)

Permissions must match the GitHub App

The permissions in CUSTOM_ROLE_PERMISSIONS must be a subset of what the GitHub App is installed with. When the installation lookup includes its granted permissions, custom-role permissions are required by default: if an installation does not grant one, the mint reports the missing permission before making the token request. If GitHub omits that map, the mint preserves the requested permissions and lets the token request validate them. During a built-in permission rollout, only permissions explicitly listed in the mint's optionalRolePermissions map may be omitted; see the permission rollout runbook.

Fallback proxy behavior

When FALLBACK_MINT_URL is set, the standalone mint acts as a transparent proxy for roles it does not handle locally:

RequestBehavior
POST /v1/token with a role in ROLE_APP_IDSHandled locally
POST /v1/token with an unknown roleForwarded to FALLBACK_MINT_URL
GET /healthAlways handled locally
GET /v1/statusAlways handled locally

The proxy forwards the original OIDC bearer token and request body to the upstream mint, and returns the upstream response verbatim. The upstream mint performs its own OIDC validation — the shared public GitHub Apps must be installed in your organization for proxied requests to succeed.

When FALLBACK_MINT_URL is not set, requests for roles without local PEMs are rejected with a 403 Forbidden response.

Verifying the setup

Check the health endpoint

bash
curl http://localhost:8080/health
# {"status":"ok"}

Test from a GitHub Actions workflow

Create a test workflow that requests a token for your custom role:

yaml
name: Test standalone mint
on: workflow_dispatch

permissions:
  id-token: write
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - name: Get OIDC token
        id: oidc
        run: |
          OIDC_TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=fullsend-mint" | jq -r '.value')
          echo "::add-mask::$OIDC_TOKEN"
          echo "token=$OIDC_TOKEN" >> "$GITHUB_OUTPUT"

      - name: Request installation token
        run: |
          curl -s -X POST "${{ vars.FULLSEND_MINT_URL }}/v1/token" \
            -H "Authorization: Bearer ${{ steps.oidc.outputs.token }}" \
            -H "Content-Type: application/json" \
            -d '{"role":"scanner","level":"read","repos":["${{ github.event.repository.name }}"]}'

Complete example

This example sets up a standalone mint with:

  • A custom scanner role using your own GitHub App
  • The built-in triage role using your own GitHub App
  • All other roles proxied to the hosted mint
bash
# 1. Create GitHub Apps (see Step 1 above)
#    - myorg-triage (App ID: 4087047)
#    - myorg-scanner (App ID: 5555555)

# 2. Set up PEMs
mkdir -p pems
cp ~/Downloads/myorg-triage.private-key.pem pems/triage.pem
cp ~/Downloads/myorg-scanner.private-key.pem pems/scanner.pem
chmod 600 pems/*.pem

# 3. Build
git clone https://github.com/fullsend-ai/fullsend.git
cd fullsend/cmd/mint && go build -o fullsend-mint .

# 4. Run
export ALLOWED_ORGS="myorg"
export ROLE_APP_IDS='{"triage":"4087047","scanner":"5555555"}'
export PEM_DIR="./pems"
export ALLOWED_WORKFLOW_FILES="*"
export FALLBACK_MINT_URL="https://mint.fullsend.sh"
export CUSTOM_ROLE_PERMISSIONS='{"scanner":{"contents":"read","security_events":"write","metadata":"read"}}'

./fullsend-mint

# 5. Expose (in another terminal)
cloudflared tunnel --url http://localhost:8080

# 6. Set the mint URL for your org
gh api -X POST /orgs/myorg/actions/variables \
  -f name=FULLSEND_MINT_URL \
  -f value="https://your-tunnel-url.trycloudflare.com" \
  -f visibility=all

See also