Skip to main content
Meerkat’s auth story has two layers. The fast path is environment variables (RKAT_ANTHROPIC_API_KEY, RKAT_OPENAI_API_KEY, RKAT_GEMINI_API_KEY, or the matching provider-native names) - set a key matching the effective model/provider. A no-hint rkat run does not select a provider by discovering whichever key is present. The powerful path is realm-scoped bindings: declare a realm in your config, register global credentials via rkat auth login or binding-scoped credentials through REST/RPC, and pass --auth-binding <realm>:<binding> for a session or structural auth_binding on a mob-member spawn.
Interactive OAuth/device rkat auth login provisions nonsecret declarations for the reserved global realm in the home-rooted doc at ~/.rkat/config.toml; tokens are stored separately through TokenStore. Scripted API-key login only stores a token and needs matching binding config. Once configured, global is the implicit tail of a workspace realm’s chain, so a single sign-in is inherited by every workspace realm (credential reads inherit down the chain). For a binding owned by a specific realm such as prod:openai, manage that realm’s config entries directly. HostAuthService and RPC/REST credential writes are strict-owner and reject a child-addressed inherited write with the owner identity. The current CLI logout exception can clear an ancestor’s stored credential when the loaded config resolves a child address to that owner; explicitly address the true owner instead. See Realm inheritance.

What this guide is for

Use this guide when you need more than the env-var fast path:
  • explicit realm-scoped bindings
  • OAuth or cloud-IAM-backed auth
  • hot-swapping provider identity
  • per-mob-member credential overrides
Both paths route through the same ProviderRuntimeRegistry. The environment fallback is a synthesized binding with a typed origin = "synthetic_env_default" and CredentialSourceSpec::Env. It is ephemeral, is not a durable realm, and cannot participate in inheritance.

The fast path: env vars

On an otherwise unconfigured setup, Meerkat synthesizes an ephemeral binding that reads the key, then uses the normal backend/auth compatibility and provider-resolution path. RKAT_* variants take precedence over provider-native names. With no model/provider/binding hints or configured model, the catalog default is OpenAI gpt-6-astra, so an Anthropic key alone does not make that default run work. Explicit models, binding defaults, and compatible configured models retain their normal selection precedence; configured bindings can supply credentials instead of this environment path. If both public OpenAI and unprefixed Azure OpenAI env vars are present, the public OpenAI env default wins. Use RKAT_AZURE_OPENAI_API_KEY plus RKAT_AZURE_OPENAI_ENDPOINT, or an explicit realm binding, to make Azure OpenAI the env default in that case. Azure image generation can additionally use RKAT_AZURE_OPENAI_IMAGE_GENERATION_DEPLOYMENT and RKAT_AZURE_OPENAI_IMAGE_GENERATION_API_VERSION (or their unprefixed equivalents).
openai_api with an API key and chatgpt_backend with ChatGPT OAuth are different account surfaces. The first uses an OpenAI API organization/project; the second uses ChatGPT/Codex subscription and workspace entitlement. A model listed by rkat models is supported by Meerkat but may still be unavailable to the active account. Keep the backend, auth method, and binding default_model aligned.

The powerful path: realms + bindings

A realm is a named collection of backend profiles, auth profiles, and provider bindings. It lets you:
  • Run multiple sessions against different accounts or tenants
  • Mix OAuth (Claude.ai Pro/Max), api_key (keyed by CI), and cloud IAM (Bedrock/Vertex/Foundry) in one process
  • Hot-swap a session’s active binding mid-conversation
  • Give different mob members different credentials

Realm shape

Azure OpenAI uses the same provider id (openai) with an Azure-specific backend/auth pair:

Log in

Interactive rkat auth login openai provisions the ChatGPT OAuth backend. Interactive login uses the provider’s fixed OAuth/device flow; --backend and --method are accepted but ignored in this mode. For scripted API-key storage, first add a compatible managed-store binding to ~/.rkat/config.toml (or reuse matching existing declarations):
Then store the key and explicitly select that configured binding:
The scripted command saves a token under global:default_openai; it does not provision a backend/auth/binding or materialize a global config section. In this mode, --method chooses an allowed persisted-secret mode (default api_key), and --backend is recorded in token metadata, not used to provision or fully validate a runtime backend/auth pair. Without the matching managed-store declarations, use the environment-key fast path instead. Public OpenAI API keys and interactive ChatGPT OAuth belong to different account systems and can expose different model sets. GitHub Copilot uses device authorization and provisions three routes over one shared credential account:
The generated routes are global:copilot_openai, global:copilot_anthropic, and global:copilot_gemini; the Gemini route’s default_model is gemini-3.8-flash rather than the API-key Gemini default. They remain ordinary OpenAI, Anthropic, and Gemini provider identities; credential_account = "github_copilot" only makes their GitHub token, derived Copilot token cache, AuthMachine lifecycle, status, and logout shared. Meerkat queries the signed-in account’s /models endpoint and rejects a known-unavailable model/dialect before dispatch. If discovery is temporarily unavailable, auth still succeeds and the request is attempted normally. A known-unavailable error includes the compatible model IDs observed for that account. An ID outside Meerkat’s built-in catalog runs when the request names a route of the account: the account’s model list defines it, protocol and limits included (see GitHub Copilot configured once); a [models.<id>] entry for it still works, and wins. The device sign-in’s routes send a Copilot token that Meerkat obtains from the GitHub sign-in by a token exchange and replaces before it expires. A temporary failure of that exchange (a network failure or timeout, HTTP 408, 429 or 5xx, or a GitHub API rate limit) does not fail turns: the current token, minted from the current sign-in, keeps serving until it expires, and the exchange is tried again after its Retry-After (at least a second later), or after 30 seconds without one. Once no valid token is left, a turn fails with a retryable error (rate limited, server error, network timeout), which the agent loop retries. A token Copilot rejects with a 401 is replaced on its next use, and the account’s routes stay resolvable. Restoring or waking a session, or building one whose output budget is set explicitly or in the configuration, succeeds during such an outage on the account’s last known model list; the first request then performs the exchange. An exchange 401, or a 403 that asks for an account action, still needs a new sign-in.
GitHub does not currently publish a stable third-party Copilot inference contract. This native integration uses the observed GitHub device client identity and CAPI headers provisionally, without linking the Copilot SDK or CLI. GitHub may change or restrict that contract. Use it only with an account entitled to GitHub Copilot and review GitHub’s current terms before deployment.
Interactive login writes nonsecret backend/auth/binding declarations to the home-rooted global doc (~/.rkat/config.toml), so configured sign-in is inherited by workspace realms via the chain tail. Secret tokens are separate: the stock CLI’s file-backed TokenStore uses <platform-config>/meerkat/credentials/global/<credential-stem>.json, where <platform-config> is dirs::config_dir(). Files are created with mode 0600 on Unix. --user-config-root relocates the config doc, not this token root; custom hosts may choose a keyring or another TokenStore. To use a binding owned by another realm (for example prod:openai), declare that binding in the owning realm’s config and use an external source such as the env profiles above, or complete login through REST/RPC with explicit realm_id, binding_id, and profile_id. CLI login never populates a prod managed-store profile; the host/RPC/REST write paths require the true owner.

GitHub Copilot with a service-account token

The Copilot routes can also authenticate with a GitHub token you supply, with no device sign-in, the way GitHub Copilot CLI signs in with COPILOT_GITHUB_TOKEN: auth_method = "github_copilot_token". Use it for CI jobs, servers and service accounts. The token itself is the Copilot API credential; the first time a route uses it, Meerkat looks up the GitHub login, the Copilot plan and the plan’s API host, and nothing is exchanged. Token routes identify themselves to the Copilot API with Copilot-Integration-Id: copilot-developer-cli, GitHub Copilot CLI’s integration id, which the Copilot API accepts for personal access tokens and OAuth tokens alike; it refuses vscode-chat, the device sign-in’s default, for a personal access token. An integration_id in the backend options replaces it on every request of that route (GitHub’s identity lookup sends none). GitHub’s identity lookup may not answer a fine-grained personal access token that the Copilot API accepts (it can answer 403 or 404). The route then uses the default Copilot API host, https://api.githubcopilot.com, with no login or plan, and only once the account’s model list loads there: a token that cannot list the models is never used. A 401 or 403 from that list is Copilot refusing the token (a missing Copilot Requests permission or seat, a policy), not a revoked token: the request fails as refresh_failed, the same token is asked about again after a minute, and it works once that is fixed. Only the identity lookup’s own 401 marks a token revoked. Once such a route serves, rkat auth test reports that GitHub named no account. Write the three routes once, then provide the token in the environment:
--from-env writes these routes to ~/.rkat/config.toml and stores nothing. When one of the variables is set, it checks the token first and prints the account, for example ok: GitHub Copilot ready as svc-bot (plan business) via COPILOT_GITHUB_TOKEN; routes global:copilot_openai, global:copilot_anthropic, global:copilot_gemini. The routes read COPILOT_GITHUB_TOKEN, then GH_TOKEN, then GITHUB_TOKEN, Copilot CLI’s order, and RKAT_<NAME> before each; a variable that is set but empty is skipped, so an empty COPILOT_GITHUB_TOKEN does not hide GH_TOKEN:
Without any config, while [realm.global] configures no Copilot route, the three routes exist anyway: naming one, as in --auth-binding global:copilot_anthropic, resolves it in token mode from COPILOT_GITHUB_TOKEN, then GH_TOKEN, then GITHUB_TOKEN, so a CI job needs only the variable. Nothing selects these routes by default, so an API-key variable such as ANTHROPIC_API_KEY keeps its role for requests that name no binding. Any Copilot route in [realm.global] turns them off: the device sign-in’s routes, the routes above, a copilot backend under another name, or a binding on the github_copilot account. The zero-config Gemini route has no default model, so pass --model with it. rkat auth test and rkat auth status read these routes too. To store the token instead, as copilot login --with-token does, pipe it in (or pass --secret):
The token is checked before it is stored, and a token GitHub or Copilot refuses is never stored. It is kept as a static bearer under the shared global:account:github_copilot credential, and the routes read source = { kind = "managed_store" }. RPC and REST store one with auth/profile/create (auth_method = "github_copilot_token") on a route with that source. Other sources need only a different source line, the same on all three routes (the routes of one account read its token from one source: a route over another source is refused before any request):
A command source with refresh_interval_ms publishes each token with that lifetime: a minute before it ends, the next request runs the command again, so the command runs again refresh_interval_ms minus one minute after each run. refresh_interval_ms must therefore be at least 61000 (the lifetime counts in whole seconds and must outlast that minute), and several minutes serve better: 61000 runs the command again about every second, 600000 about every nine minutes. A shorter one is refused before the command runs, as unsupported_combination (see below), rather than running it on every request. The route reports that lifetime as its token’s expiry, so a session built within it reuses the published token: building a session neither publishes the token again nor runs the command, and it repeats neither the account lookup nor the model listing. An external resolver is asked again after the Copilot API refuses a token, so a rotated token heals that request. Token kinds (read from the documented prefix only): These follow GitHub’s requirements for GitHub Copilot CLI’s personal access tokens: a fine-grained token owned by your personal account with the Copilot Requests permission works, and a classic token is not supported. Choosing a token: for automation that belongs to an organization, prefer a GitHub App installation token. Its usage is billed to the organization and follows its Copilot policies, and it needs no seat. A machine user’s fine-grained token needs a Copilot seat of its own, and a seat is a license for one user: keep machine-user tokens to single-purpose automation rather than one token serving many people. Either way, use a dedicated machine user or App, grant only Copilot Requests, and set a short expiry. What each refusal looks like: Which token value was refused or logged out is remembered only in the process’s memory, by the account’s AuthMachine lease. A restart, or any other publication on the account that names no token value (a sign-in, a stored token, a refresh, the rollback of a failed login), replaces that record: a refused token is then sent once more, and refused again, and a logged-out token is used again. A refusal withdraws only the publication it names, so GitHub’s refusal of a token the account has already replaced leaves the newer token valid (the stale_credential row). rkat auth status copilot_openai shows auth_method: github_copilot_token and the account’s lifecycle (absent before a process first uses a token from the environment, then valid, reauth_required or released). rkat auth test copilot_openai resolves the route and prints account: and plan:; once a route serves a GitHub App installation token, or a token GitHub’s identity lookup does not answer, it prints account: not named by GitHub (...) and no plan:. rkat auth login copilot --non-interactive names such an account an unnamed GitHub account (an installation token github.com installation (server-to-server)), and refuses to store a token whose model listing Copilot refuses, with Copilot’s reason. A Rust host checks a token without storing it, as the CLI does before it stores one, with meerkat_copilot::verify_operator_token(token, &config), where config is CopilotBackendConfig::from_token_options of the routes’ backend options, so the check sends what the routes send: it refuses a classic personal access token, looks up the Copilot identity and lists the account’s models, which must succeed, with no AuthMachine, token store or cache effect, and answers the token’s CopilotAccountIdentity (login, plan, api_base, installation for a GitHub App installation token, and looked_up, whether GitHub’s identity lookup answered for the token). CopilotAuthorizer::account_identity reads the identity a route’s token resolved to. The token goes only to api.github.com (the identity lookup) and to an https githubcopilot.com API host, and a redirect is refused. Meerkat keeps the token out of its errors and log lines: a failed lookup or model listing is reported by its status and GitHub’s own error message, never by the response body, and a token the message quotes shows as <redacted>. A token a provider, proxy or gateway echoes back is redacted too: the route removes the last four bearer values its account signed requests with (the operator’s GitHub token in token mode, the derived Copilot token after a device sign-in) from rejection bodies, stream error events, data lines that do not parse and the provider error fields it logs, and every error keeps its class. The bearer, and every header value the route signs a provider request with, is marked sensitive, so a request’s Debug output does not print it. Only a verbatim echo is caught: an echo that a provider or gateway truncated, re-encoded or otherwise transformed can keep part of a token. Meerkat stores no token it reads from the environment, a command or a resolver; a stored token uses the configured credential store, like an API key. A token in the host environment is inherited by shell tools the host runs without confinement, so hosts that run shell tools unconfined should prefer managed_store, command or external_resolver. A credential command’s stderr becomes error text: a minting script must not print the token there. The three reserved routes and the github_copilot account hold one method at a time. rkat auth login copilot (device sign-in) and rkat auth login copilot --non-interactive (token) each refuse routes configured for the other. To switch, run rkat auth logout global:copilot_openai and remove the copilot_openai, copilot_anthropic and copilot_gemini tables under [realm.global]; to use both, define more routes on another account, for example credential_account = "github_copilot_ci". Client identity headers stay in the backend options (integration_id, editor_version, …), equal on every route of the account. A mob member names its route with --auth-binding global:copilot_anthropic, or {"realm": "global", "binding": "copilot_anthropic"} on the wire. The warning above about GitHub’s unpublished Copilot contract applies to this mode too.
The token route is tested against local stand-ins for GitHub and the Copilot API. Its check against GitHub with a real fine-grained personal access token has not run yet, so these answers are not yet observed live:
  • whether GitHub’s identity lookup answers a fine-grained token of a Copilot Business or Enterprise machine user, with its login and API host (if it does not, the route uses the default host, above);
  • whether the per-plan API hosts (api.business.githubcopilot.com, api.enterprise.githubcopilot.com) accept such a token under copilot-developer-cli, and whether they accept Bearer for gho_ and ghu_ tokens;
  • Claude models (/v1/messages) with a fine-grained token;
  • whether /models lists each model’s supported endpoints and an enabled policy for every plan’s token callers (without them a route falls back to its default endpoint);
  • what the Copilot API answers for a revoked or expired token used directly as its bearer, and for its own rate limits and quotas (status, body and Retry-After form);
  • whether GitHub’s identity lookup ever answers 401 for a valid token that lacks Copilot access, which would hold that token refused until its value changes, and whether it sends rate-limit headers;
  • what a GitHub App installation token gets when its organization has not enabled server-to-server Copilot requests or the App lacks Copilot Requests: a 401 there holds the token refused until its value changes;
  • the body form of Copilot API errors: public reports show plain text, which Meerkat reports as “no message” instead of GitHub’s reason;
  • whether an organization that turns off the Copilot CLI policy refuses token route requests, which present as copilot-developer-cli;
  • whether a fine-grained token works on Copilot Individual plans at all (one public report shows a 401).

GitHub Copilot configured once

The account can also be configured with one section instead of the nine backend, auth and binding tables above. [realm.<realm>.copilot] states once where the account’s routes read the GitHub token:
The realm then has the account’s three routes, copilot_openai, copilot_anthropic and copilot_gemini, on its github_copilot credential account, each with auth_method = "github_copilot_token" over that source: the routes the tables above declare, under the same names, so rkat auth test, rkat auth status, rkat auth logout and --auth-binding global:copilot_anthropic work as before, and realm/get lists them. The realm lists (rkat auth realms, realm/list and GET /realms) count a realm’s explicit tables only, so they show no backends, auth profiles or bindings for a realm configured only by the section. No route has a default model. Like the explicit tables, they are the realm’s routes for their providers: with no other binding of a provider in the realm, a request for a catalog model of that provider that names no binding runs on the account, before an API key variable. The section reads an env or an external_resolver source only. A secret in config, a stored token, a credential command and the device sign-in use the explicit tables. A realm configures its account one way: the section next to a table that declares one of its routes, or next to another binding on its github_copilot account, is refused when the realm loads, and both rkat auth login copilot forms refuse a [realm.global] that has the section. A section in another realm is another account: [realm.team.copilot] configures team:account:github_copilot. Choosing a model:
  • A model of Meerkat’s catalog runs on the account’s route of its provider. A request that names any route of the account is served on the account’s route of the model’s provider; a request that also names a provider keeps the strict check.
  • A model only your account lists (no catalog row, no [models.<id>] entry) runs when the request names any route of the account:
    The build asks the account’s model list about the model, after the route has looked the account up. The endpoints Copilot lists for the model choose the protocol, never its vendor: a model on /v1/messages runs on the Anthropic Messages wire, otherwise a model on /responses or /chat/completions runs on the OpenAI wire, Responses first. The session moves to the account’s route of that protocol, whichever route of the account it named. The model’s limits come from the same list: its context window, prompt limit and output limit, and whether it takes images. Tool calls, streaming, structured outputs and thinking budgets are checked against the list on every request, as for every Copilot model. Nothing about the model is stored: a resumed session asks the account again.
  • A request that names no binding cannot reach such a model: the account is asked only on a route the request names. Without a provider the request is refused as before (“cannot infer provider from model”); with one it is refused after its credential resolves (model '<provider>:<id>' is absent from the effective model registry), or by that resolution’s own error. Name a route of the account as above. A live model switch or a fallback target to such a model, and any live reconfigure of a session that runs one (a provider parameter or binding change too), need a [models.<id>] entry.
A model only the account defines is refused before any model request is made, and no other route or credential is tried in its place: A [models.<id>] entry keeps owning its model: the account is never asked about it, and its provider still decides the route. To move from the explicit tables to the section, remove the copilot_openai, copilot_anthropic and copilot_gemini tables of the realm and add the section with the same source; the route names and the account stay, so sessions that name the routes resume on them.
Known limitation: Meerkat Connect’s provider setup and first-target setup do not yet list the routes a [copilot] section declares, so a realm whose Copilot account is configured only by the section can look unconfigured there. Use the explicit tables with Connect until a later release. With the explicit tables, Connect can offer all three Copilot routes as targets for a model of one provider family; whichever is chosen, the session runs on the account’s route of the model’s family, on the same credential (choosing copilot_openai for an Anthropic model runs on copilot_anthropic).

Use the binding

Run these from the workspace directory where you wrote ./.rkat/realms/prod/config.toml; adjust both paths together if you choose a different root. --realm prod selects that config head and session realm. --auth-binding selects an already-configured credential identity, not a config file or the session realm.

Subcommands

For CLI auth administration, --realm prod is an owner-section selector inside the scope’s already-loaded effective config, normally the workspace/global chain. Unlike run --realm prod, it does not by itself load a separate <state-root>/prod/config.toml or inherit missing profiles/bindings for profile, status, test, or refresh. The default owner section is global; already-loaded multi-section documents also work. The prod examples below require its sections to be reachable first. For the separate file above, add parent = "prod" to the [realm.<workspace-realm-id>] section of the workspace head’s config.toml, with both documents under $PWD/.rkat/realms. Find that workspace ID with:
Alternatively, place the prod sections in the already-loaded workspace or global document. Merely passing auth --realm prod is not this setup.
1

Login and profile management

2

Status and refresh

rkat auth refresh is a no-op for api_key / azure_api_key / static_bearer auth methods (nothing to refresh). For OAuth-backed methods it exchanges the persisted refresh token for a fresh access token and writes the new bundle back to the TokenStore.
3

Delete

CLI logout ownership

Current CLI exception: if the loaded config resolves child:<binding> to an inherited owner, rkat auth logout child:<binding> can clear that owner’s stored credential. This includes a shared credential account used by other routes. Explicitly address the true owner when signing out. A local same-name binding shadows its ancestor. If config resolution fails, logout falls back to the literal requested token key; a missing child is not automatically an inherited deletion. A bare logout binding means global, and --realm does not retarget it. HostAuthService and RPC/REST still enforce strict-owner credential writes, and config get / set / patch retain the raw-head read/write split.

Release auth smoke

make e2e-auth is the CI-safe auth smoke lane. It drives Meerkat’s browser OAuth plumbing against a local OAuth fixture, verifies token-store and AuthMachine lifecycle behavior across login, status, refresh, restart, resolve, and logout, and skips optional live-provider canaries when their seeded credentials are absent. Real third-party browser login pages remain a manual pre-release check. Do not automate provider-hosted OpenAI, Anthropic, or Google login pages in CI; those pages are intentionally subject to MFA, CAPTCHA, account policy, and provider UI changes. Manual browser-login release checklist:
  • Start login for the target provider.
  • Confirm the system browser opens the provider-hosted login or consent page.
  • Complete the callback and confirm Meerkat reports the login complete.
  • Check auth status and confirm the target binding is valid.
  • Run one tiny call through that binding.
  • Logout and confirm auth status is cleared and the credential is gone.

Auth methods

Each provider’s runtime accepts a matrix of (backend_kind, auth_method) pairs. The table below summarizes what’s wired today. External commands (CredentialSourceSpec::Command) and file descriptors (CredentialSourceSpec::FileDescriptor) let host applications inject tokens without going through OAuth. Credential header values are marked sensitive on every OpenAI Responses, OpenAI-compatible Chat Completions and Anthropic Messages request, so the request’s Debug output does not print them and HTTP/2 never indexes them: the values a dynamic authorizer supplies (an external_authorizer route’s, a Copilot route’s bearer) and a static API key’s (the OpenAI Authorization bearer, Azure OpenAI’s api-key, the compatible client’s bearer, Anthropic’s x-api-key). The Gemini client’s credential headers are not marked. A value that is not a valid HTTP header value fails the request as an authentication failure (“a credential header value is not a valid HTTP header value”) before it is sent. Those clients also pass what they read from a text generation response (a rejection body, a stream error event, a data line that does not parse, the provider error fields they log) through the authorizer’s HttpAuthorizer::redact_response_diagnostic before it reaches a caller or a log; its default changes nothing, and the Copilot authorizer redacts the bearers it signed with. Only a verbatim echo is redacted, and nothing redacts a static API key a gateway echoes back. LlmError::redact_diagnostics, meerkat_llm_core::streaming::redact_stream_diagnostics and meerkat_llm_core::http::sensitive_header_value serve an embedder’s own client.

Credential sources

The source field on an auth profile declares where credentials come from.
Storing api keys inline in config files works but shouldn’t be checked into git. Prefer managed_store or env. The stock CLI uses its file-backed TokenStore profile (mode 0600 on Unix). Native hosts can enable native-keyring and deliberately select the keyring-aware profile.
FileDescriptor is a host-integration seam rather than a plain standalone resolver path. It requires a host-scoped reader/injection path, so treat it as an advanced embedding surface rather than a generic CLI config trick.

Hot-Swap Between Turns

When a session is created with --auth-binding prod:default, its persisted SessionMetadata.auth_binding carries that binding through resume and hot-swap. Changing the model/provider on an RPC turn/start request keeps the binding by default; pass an explicit auth_binding on the turn request to scope the swap to a different realm or binding.
The factory re-enters ProviderRuntimeRegistry::resolve with the new binding. It does not silently replace an explicit configured binding with the environment fallback.

Per-mob-member override

The mob runtime does not promote ambient credentials into member authority. Live model-backed spawn paths that require credentials must receive an explicit binding, either from the compiled member declaration/overlay or as auth_binding on the host-side spawn request:
Members in the same mob can use different providers or tenants without cross-contamination. Public wire surfaces accept the structural auth_binding object only; the colon-joined realm:binding[:profile] form is a CLI input convenience and is converted before it crosses the wire boundary.

Native desktop hosts

Native applications should use HostAuthService for the complete interactive transaction instead of recreating the CLI or RPC flow. The service owns strict binding-owner resolution, PKCE and one-time state, exchange, coordinated TokenStore persistence, AuthMachine lifecycle publication, rollback, status, and logout. The application owns only its loopback listener, browser launch, and UI. Open one persistence capability and share that exact capability between AgentFactory and HostAuthService:
Keep the MeerkatMachine alive for as long as the factory and auth service; the snippet returns it in Arc, while a real host normally stores all three in one application state object. A TokenStore open failure is returned as a fault and is never collapsed into interactive_login_required. Managed-store resolution also requires per-session AuthMachine bindings. Do not use the factory’s standalone build mode for a runtime-backed desktop session:
For a keyring-preferred desktop, enable Meerkat’s native-keyring feature and replace default_auto() with default_keyring_auto(). That profile preserves an existing CLI file credential as the per-key authority, but a brand-new key is written to the OS keyring. Use the same keyring-aware backend in every process that must see desktop-first credentials; the stock CLI intentionally uses the file profile to avoid surprise OS keychain prompts. Every token-store load, save and clear states its access: Status, Unattended (provider setup, request preparation, refresh) or Interactive (an explicit login, logout, save or clear). With a keyring-aware store:
  • status never waits for credential work and never reads the OS keychain. A credential it did not read carries credential_observation (not_observed, owner_busy, pending, denied, interaction_required or unavailable); that is never absence.
  • Provider setup and refresh admit at most one bounded keychain lookup per credential, off the async workers. Concurrent requests answer credential_access_pending at once, and capacity beyond KEYCHAIN_OPERATION_CAPACITY answers credential_store_unavailable with no call. The OS may still show its own access dialog for that one lookup.
  • While that lookup or an interactive keychain call holds a credential’s lease, provider requests, refresh, MCP and connector use answer credential_access_pending at once instead of waiting for it.
  • A denial (credential_access_denied) is not retried until an interactive login, save or clear succeeds. A denial, a refused interaction or a locked, read-only or otherwise inaccessible keychain never falls back to the file store; only a platform report that there is no credential store does.
  • Auth errors carry the same four kinds as their reason: credential_access_denied, credential_interaction_required, credential_access_pending and credential_store_unavailable.
  • Before dropping its runtime, a host settles credential work with settle_in_flight_credential_mutations(bound) and reports Unsettled { pending } instead of claiming cleanup.
The browser flow is split for native UI:
  1. Bind the loopback listener and choose its redirect URI.
  2. Call login_start(config, target, redirect_uri), then open the returned authorize_url.
  3. Pass the callback’s state and code to login_complete.
  4. Use status and logout for secret-free UI projections and sign-out.

Connector OAuth

Connector OAuth stores a credential for a SaaS provider that is neither an LLM provider binding nor an MCP server. The trusted host names a credential slot, a realm-scoped storage address such as {realm_id: "tenant-a", slot_id: "drive-work"}, plus the connector’s descriptor: issuer, client, resource, scopes, account strategy and account selection. The slot is not proof of any account. The provider-verified account is bound to the stored credential at completion and reported separately.
  • Account selection. known names the provider account up front and refuses any other verified account. discover admits the login without an account and binds the account the strategy verifies. A discover login publishes only into an empty slot: a slot that holds any credential refuses it (occupied), even for the same account, so two racing logins cannot swap grants. To re-authorize an occupied slot, log in with known and the account that status reports. A known reconnect replaces the slot’s credential only for the same account and the same issuer, client, resource and strategy; the loopback port and the requested scopes may change. Every refusal leaves the slot’s credential untouched.
  • Account strategies. The default strategy, oidc-userinfo-v1, proves the OIDC subject through the issuer’s UserInfo endpoint. Hosts with other provider evidence install a ConnectorAccountStrategy (HostAuthService::with_connector_strategy). A strategy that verifies signed ID tokens declares requires_nonce: the owner mints a nonce per attempt, puts it in the authorize URL, keeps it with the attempt and hands it to the strategy at completion.
  • Scopes. Granted scopes come from the token-endpoint response the owner parsed itself (scope_evidence: token_endpoint_response). A refresh response without scope keeps the original grant (retained_on_refresh); one that narrows a required scope, or whose subject is not the bound account, is refused and the credential is unchanged.
  • Endpoints. The issuer’s endpoints come from its RFC 8414 metadata or OpenID Connect discovery. Its issuer must match, PKCE S256 must be advertised and every endpoint must be https or loopback. Clients are public (PKCE, no client secret).
  • Native hosts use HostAuthService::connector_login_start, connector_login_complete, connector_login_cancel, connector_status and connector_logout (which disconnects a slot so it can take another account). connector_oauth_authority().bearer_token(slot) returns the access token for native use, refreshing it through the AuthMachine lifecycle; tokens never cross a wire surface.
  • RPC and REST hosts use auth/login/start and auth/login/complete with a connector target, auth/login/cancel with {connector: slot, state} and (RPC) auth/status/get with {connector: slot}:
    Completion repeats the same target with the callback’s code, state and redirect_uri; the admitted attempt’s descriptor must equal it. The result and status carry the slot, verified_account (issuer, strategy and subject) and scope_evidence as separate fields.
The same host obligations as browser logins apply: the authorize URL, state and code are bearer material for one attempt and must never reach an agent, tool result, transcript or log.

Refresh coordination

For managed credentials, refresh semantics are owned by the AuthMachine DSL. AuthMachine leases, TokenStore lookup, and refresh coordination share one credential identity: the owning realm plus binding and optional profile by default, or the owning realm plus a configured credential_account. The latter deliberately shares lifecycle and refresh across multiple route bindings; binding identity still selects the route. This is the managed runtime contract, not a claim that every CLI administrative path or non-managed cloud authorizer uses identical account resolution. On resolve, the runtime:
  1. Checks expires_at. If present and within the refresh window (AUTH_LEASE_TTL_REFRESH_WINDOW_SECS, 60 seconds before expiry), marks the lease Expiring via the DSL.
  2. Under Expiring, the next resolve triggers BeginRefresh. In-process dedup ensures only one refresh HTTP call per credential identity even under concurrent resolves; cross-process dedup via filesystem lockfile when refresh-file-lock is enabled.
  3. On successful refresh, fires CompleteRefresh and updates expires_at. On failure, RefreshFailedTransient (retry) or RefreshFailedPermanent (routes to ReauthRequired + emits an [AUTH_REAUTH_REQUIRED] system notice on the session).
The lifecycle is TLC-verified in specs/machines/auth/.

Audit logging

Every accepted lifecycle transition emits a structured tracing event:
  • target = "meerkat::auth::audit"
  • Fields: lease_key, realm, optional binding / account / profile projections, action, from_phase, to_phase
GitHub’s refusal of a Copilot token route’s token is recorded with action = mark_publication_refused, also when it changes nothing: a refusal of a token the account has already replaced, or on a lease already refused or logged out, keeps from_phase equal to to_phase. REST and RPC surfaces also emit audit events for user-initiated actions: create_profile, delete_profile, login_oauth_complete, login_device_complete, logout. These surface events have their own field shapes (for example binding_key on a user-action event), not the lifecycle transition schema. Filter any tracing::Subscriber on the meerkat::auth::audit target to build a persistent audit log without assuming every event on that target has the same fields.

Feature flags

meerkat-providers is a compatibility shim. Its feature aliases forward shared auth primitives to meerkat-auth-core; they do not select provider clients or activate provider-owned backend/auth branches: The following native methods also require their provider-owned feature on a non-WASM target: These requirements concern the named methods, not every bearer, API-key, or external-authorizer route to the same backend. Enabling the features does not make native credential mechanisms available in a browser/WASM runtime. The shipped rkat CLI enables Anthropic, OpenAI, Gemini, OAuth, native-keyring, and refresh-file locking in its dependency graph, while its stock TokenStore profile remains file-backed. Select the corresponding provider-owned features when a custom build needs the native methods above.

See also

  • Hooks - observe auth events or gate turns on credential state
  • Mobs - per-member auth_binding overrides
  • Machine Authority - rationale behind single-owner runtime state