# 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](mint-administration.md) 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](../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:
   - Go to **https://github.com/apps/YOUR-APP-SLUG/installations/new**
   - Select your organization
   - Choose which repositories the app can access (all or selected)
   - Click **Install**

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

| Variable | Description | Example |
|----------|-------------|---------|
| `ALLOWED_ORGS` | Comma-separated GitHub orgs allowed to request tokens, or `*` for public mint mode (any org; upstream-only workflow provenance) | `myorg,myorg-sandbox` or `*` |
| `ROLE_APP_IDS` | JSON map of role name to GitHub App ID (use plain role names, not org-prefixed) | `{"triage":"4087047","scanner":"5555555"}` |
| `PEM_DIR` | Path to directory containing `{role}.pem` files | `./pems` |

### Optional variables

| Variable | Description | Example |
|----------|-------------|---------|
| `ALLOWED_WORKFLOW_FILES` | Comma-separated workflow file allowlist; `*` for all | `*` |
| `FALLBACK_MINT_URL` | Upstream mint URL for roles without local PEMs | `https://mint.fullsend.sh` |
| `CUSTOM_ROLE_PERMISSIONS` | JSON map of custom role permissions (see below) | `{"scanner":{"contents":"read"}}` |
| `PER_REPO_WIF_REPOS` | Comma-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_REPOS` | Comma-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` |
| `PORT` | HTTP listen port | `8080` (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](../../ADRs/0082-workflow-host-allow-list.md) §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.

| Level | Behavior |
|-------|----------|
| `"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](https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app). 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)

Role-prefixed identifiers (environment variables, GitHub Actions secrets and variables such as `FULLSEND_<ROLE>_APP_PRIVATE_KEY`, `FULLSEND_<ROLE>_CLIENT_ID`, `<ROLE>_FULLSEND_MODEL`, and `FULLSEND_FOREIGN_<ROLE>_REPOS`) map the role with `role.upper().replace("-", "_")`. For `ci-check` that is `CI_CHECK`, so the foreign allow-list variable is `FULLSEND_FOREIGN_CI_CHECK_REPOS` and the model override is `CI_CHECK_FULLSEND_MODEL`. Roles that differ only by hyphen vs underscore (`ci-check` vs `ci_check`) share an identifier.

### 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](infrastructure-reference.md#roll-out-a-github-app-permission).

## Fallback proxy behavior

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

| Request | Behavior |
|---------|----------|
| `POST /v1/token` with a role in `ROLE_APP_IDS` | Handled locally |
| `POST /v1/token` with an unknown role | Forwarded to `FALLBACK_MINT_URL` |
| `GET /health` | Always handled locally |
| `GET /v1/status` | Always 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"}
```

### Check status via the CLI

If `FULLSEND_MINT_URL` is set (or you pass `--mint-url`), the CLI can
query the mint's `/v1/status` endpoint using auto-discovered GitHub
credentials — but only GitHub Actions OIDC succeeds against a standalone
mint built per Step 3 above (`go build -o fullsend-mint .`, no
`-tags github`): the `GH_TOKEN` / `GITHUB_TOKEN` / `gh auth token`
fallback always returns HTTP 401 unless the binary was compiled with
`-tags github` and `StatusGitHubGroup` is set to a non-empty
`ORG/TEAM` (see [Enabling optional
validators](infrastructure-reference.md#status-endpoint)). Run it from
within a GitHub Actions workflow to use OIDC:

```bash
fullsend mint status --mint-url="$FULLSEND_MINT_URL"
```

Under GitHub Actions OIDC, this reports the mint's version, build commit,
the calling workflow's organization, configured roles, and workflow host
repos — without requiring any GCP IAM roles. It does not list all enrolled
organizations; that field (`allowed_orgs`) is only populated on the
non-OIDC (GitHub token) path, which a default standalone mint rejects with
HTTP 401 as described above. To verify locally without GitHub Actions
OIDC, use the health endpoint above instead.

### 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

- [Mint service administration](mint-administration.md) — Managing the hosted GCP mint
- [Bring Your Own Agent](../user/bring-your-own-agent.md) — Building custom agents and configuring existing ones
- [Getting Started](../getting-started/) — End-user setup
