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.
The chain itself lives in @agntn/credentials now. One hunt, not a copy in every library. Why does it start gh and glab without your token variables? Because glab config get token prints GITLAB_TOKEN for whatever host you ask about. That's step 2 answering again, with the wrong host attached. Env already had its turn.
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.
resolveToken("github", { account: "octocat" }) asks gh auth token --user=octocat, then that login's entry under users in hosts.yml, and never falls back to the active account. The env step still comes first though, so a token found this way is a candidate, not proof. users.authenticated() says whose it really is, and the agent tools check it once per account before the first write and pin the result, same as the default credential.
Anonymous on purpose
An empty string is a real token here. It means make unauthenticated calls:
const anonymous = await 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.
Cloudflare Artifacts
Artifacts doesn't fit the table above. It wants a Cloudflare API token plus the account that owns the namespaces, and it has no guest mode at all. So createProvider("artifacts") asks @agntn/credentials/cloudflare instead:
| Step | Token | Account |
|---|---|---|
| 1. explicit | { token } | { accountId } |
| 2. env | CLOUDFLARE_API_TOKEN | AGNTN_CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_ACCOUNT_ID |
| 3. CLI | the cf auth login session | the session, when it reaches exactly one account |
A token from steps 1 or 2 needs an account from those steps too. { token: "" } isn't anonymous here, it means go look. The session token expires, so the provider asks the chain again on every request instead of keeping one token forever. Details on the Artifacts page.
Explicit token and host
const github = await createProvider("github", { token: process.env.GITHUB_TOKEN });
const gitlab = await createProvider("gitlab", {
token: "glpat-…",
baseURL: "https://gitlab.example.com", // /api/v4 is added when missing
});
const gitea = await createProvider("gitea", {
baseURL: "https://codeberg.org", // /api/v1 is added when missing
});
const gitbucket = await 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> |
| Artifacts | Authorization | Bearer <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 |
| Artifacts | FORGES_ARTIFACTS_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,issues.update,issues.createComment,pullRequests.create,pullRequests.update,pullRequests.merge,pullRequests.createComment,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.