Authentication
The chain
createProvider(platform, config?) looks for a token in four places and stops at the first hit:
| Step | GitHub | GitLab | Gitea |
|---|---|---|---|
| 1. explicit | { token } | { token } | { token } |
| 2. env | GH_TOKEN, GITHUB_TOKEN | GITLAB_TOKEN, GL_TOKEN, GITLAB_PAT | GITEA_TOKEN |
| 3. CLI | gh auth token --hostname <host> | glab config get token --host <host> | none |
| 4. config file | ~/.config/gh/hosts.yml | ~/.config/glab-cli/config.yml | ~/.config/tea/config.yml |
Steps 3 and 4 use the hostname from baseURL, or the platform default. So a GitHub Enterprise baseURL asks gh for that host's token, not for github.com's. Small thing, saves a confusing 401.
When nothing matches, createProvider throws AuthenticationError and tells you which env var to set and which login to run. It never goes anonymous behind your back.
import { resolveToken } from "@agntn/forges";
const found = resolveToken("github"); // { token, source: "env" | "cli" | "config" | "explicit" } or null
resolveToken is the same chain without a provider. Handy as a preflight check.
Anonymous on purpose
An empty string is a real token here. It means make unauthenticated calls:
const anonymous = createProvider("github", { token: "" });
await anonymous.repos.get("nitrojs", "nitro"); // public data, 60 requests an hour per address
The check inside is token !== undefined, not a falsy check. "" is a decision, undefined means go look. The agent tools use exactly this for reads when no credential exists, see Agents.
Explicit token and host
const github = createProvider("github", { token: process.env.GITHUB_TOKEN });
const gitlab = createProvider("gitlab", {
token: "glpat-…",
baseURL: "https://gitlab.example.com", // /api/v4 is added when missing
});
const gitea = createProvider("gitea", {
baseURL: "https://codeberg.org", // /api/v1 is added when missing
});
const gitbucket = createProvider("github", {
token: "…",
baseURL: "https://gitbucket.example.com/api/v3",
});
GitLab and Gitea normalize the base URL, so the bare host works. GitHub takes the URL as given, so GitBucket and GitHub Enterprise need /api/v3 spelled out.
Headers
| Platform | Header | Value |
|---|---|---|
| GitHub, GitBucket | Authorization | token <token> |
| GitLab | Private-Token | <token> |
| Gitea, Forgejo | Authorization | token <token> |
You never set these. They are here so a proxy log makes sense.
Self hosted endpoints for agents
The MCP server and the extensions read one more variable per platform from the process environment:
| Platform | Variable |
|---|---|
| GitHub, GitBucket | FORGES_GITHUB_BASE_URL |
| GitLab | FORGES_GITLAB_BASE_URL |
| Gitea, Forgejo | FORGES_GITEA_BASE_URL |
The value is the full API base URL. It is never a tool argument, so a model cannot point a call at another host. And a failure message never repeats the endpoint, so the host stays out of the model's context even when the platform answers with an error.
What a token buys
- The cache is keyed by base URL and a hash of the token. Two providers in one process never read each other's answers.
users.authenticated()says who the token is.- Writes need one on every platform:
issues.create,pullRequests.create,threads.reply,threads.resolve,threads.unresolve. - GitHub review threads go through GraphQL and GraphQL has no anonymous access. GitLab discussions answer 401 without a token, even on a public project, which surprised me. Gitea review comments read fine anonymously on public repos.