| title | Settings |
|---|---|
| description | Layered developer-surface settings — enabled channels, model defaults + gateway, builtin tools — with a user layer and an enterprise-managed (MDM) layer. |
| order | 8 |
Forge settings are the developer-surface configuration: which channels are offered/enabled, the default model and the model gateway endpoint, and which builtin tools are offered. They layer, with a user level and an enterprise managed level an org can drop via MDM — modeled on Claude Code settings + managed settings.
Settings vs. policy. Settings are the positive surface — enablement, defaults, gateway injection. They are separate from platform policy, the negative surface (deny / restrict / tighten), which the control plane injects server-side. A managed settings lock (an authoritative allowlist) is the positive counterpart to a policy deny; both can coexist.
Highest wins. A managed value cannot be overridden by a lower layer.
| # | Layer | Source |
|---|---|---|
| 1 | Managed | managed-settings.json in the fixed OS system dir (+ managed-settings.d/*.json) — no env/flag override |
| 2 | Command line | --settings <file> |
| 3 | Project local | .forge/settings.local.json |
| 4 | Shared project | .forge/settings.json |
| 5 | User | ~/.forge/settings.json (or FORGE_USER_SETTINGS) |
Merge: list keys (channels.enabled, tools.builtins.enabled, skills.enabled, and — when unlocked — models.available_models) are unioned across layers. Scalars (models.default.*, models.gateway.*) take the highest layer's non-empty value. models.gateways merges per provider — a higher layer's entry for a given provider replaces the lower one, others are kept. env maps merge with higher keys winning.
Managed settings are not developer-overridable. They load from a fixed OS system path with no env or flag override — a developer running the shipped binary cannot redirect, replace, or drop the managed layer (matching Claude Code, whose managed path is fixed for exactly this reason). Tamper-resistance is the OS file permissions on that path: on a managed machine it is root-owned and not user-writable.
Managed lock: when a managed layer sets models.available_models, it replaces the union rather than adding to it, so no lower layer — and no developer at runtime — can widen it. Combined with the fixed path above, this is a real allowlist within the settings surface. Platform policy (server-side, control-plane injected) remains the defense-in-depth forbidden-model enforcement — use settings for the org's allowed/default set, policy to hard-forbid regardless of client state. An empty/absent available_models is "unset" (no lock), not "lock to zero models".
| OS | Path |
|---|---|
| macOS | /Library/Application Support/forge/managed-settings.json |
| Linux / WSL | /etc/forge/managed-settings.json |
| Windows | C:\Program Files\forge\managed-settings.json |
These paths are fixed — there is no env var to redirect them (a developer must not be able to point the managed layer at a file they control). Drop-in directory managed-settings.d/ next to the file is merged in alphabetical order (primary first) — name files 10-…, 20-… to control order. Deliver via MDM, an image build, or config management, and ensure the path is root-owned / not user-writable. The forge system dir is shared with the policy layer, so managed settings sit alongside policy.yaml (the deny layer) — e.g. /etc/forge/managed-settings.json next to /etc/forge/policy.yaml.
{
"channels": {
"enabled": ["slack", "telegram"]
},
"models": {
"default": { "provider": "anthropic", "model": "claude-sonnet-4-6" },
"available_models": ["anthropic/claude-sonnet-4-6", "openai/gpt-4o"],
"gateway": { "base_url": "https://gw.corp/v1", "auth_scheme": "apikey_header", "auth_header_name": "apikey" },
"gateways": [
{ "provider": "openai", "base_url": "https://gw.corp/openai/v1", "auth_scheme": "bearer", "api_key_helper": "/opt/forge/openai-helper.sh" },
{ "provider": "anthropic", "base_url": "https://gw.corp/anthropic/v1", "auth_scheme": "bearer", "api_key_helper": "/opt/forge/anthropic-helper.sh",
"env": { "OKTA_CLIENT_ID": "0oaXXXX", "OKTA_ISSUER": "https://acme.okta.com/oauth2/default" } }
]
},
"tools": {
"builtins": { "enabled": ["http_request", "datetime_now", "math_calculate"] }
},
"skills": {
"enabled": ["weather", "github"]
},
"optimizer": { "enabled": true, "upstream": "https://gateway.corp/bedrock/claude" },
"env": { "HTTP_PROXY": "http://proxy.corp:8080" }
}| Key | Type | Effect |
|---|---|---|
channels.enabled |
[]string |
Channel adapters offered/enabled by forge init / run --with / channel add |
models.default |
{provider, model} |
Default provider+model when none is given explicitly (seeds forge try/init) |
models.available_models |
[]string |
Allowlist of <provider>/<model>; a managed value is the authoritative allowlist (no lower layer or developer can widen it — see Managed lock). Empty = unset |
models.gateway |
{provider, base_url, auth_scheme, auth_header_name, api_key_helper} |
Model gateway endpoint. The singular form is the provider-less catch-all ("one URL for all providers"). Injected into the scaffolded forge.yaml model block by forge init/try, and consumed by the runtime overlay (below) — mirrors the model config fields and the outbound auth_scheme |
models.gateways |
[{provider, base_url, auth_scheme, auth_header_name, api_key_helper}] |
Per-provider model gateways (one for anthropic, one for openai, …). Consumed by the runtime overlay only (not scaffold injection). Merged per-provider (a higher layer's entry for a provider replaces the lower one). See Local-dev gateway overlay |
models.gateway(s).api_key_helper |
string |
External command that prints a short-lived token to stdout (the Claude Code apiKeyHelper contract). When set, the runtime injects a helper-minted token as the model credential per auth_scheme, instead of a native static API key. A managed helper arms the login gate; a user-layer helper uses forge auth login|logout|status |
models.gateway(s).env |
map[string]string |
Environment injected into the api_key_helper subprocess (on top of forge's own env), so the helper's config (e.g. OKTA_CLIENT_ID, OKTA_ISSUER) lives in settings instead of a shell export. Read from trusted layers only — put IDs/issuers/endpoints here, not secrets |
models.gateway(s).oidc |
{grant, issuer, authorize_url, token_url, client_id, client_secret_env, scopes, redirect_uri} |
Native OAuth/OIDC token acquisition (an alternative to api_key_helper — forge does the flow itself). grant: client_credentials (headless/CI — mints with client_secret_env, no browser) or auth_code (interactive browser login, PKCE, public client — no secret — plus silent refresh). Endpoints come from authorize_url/token_url, or are discovered from issuer's /.well-known/openid-configuration. The client secret is never stored — client_secret_env names the env var. redirect_uri (auth_code) is the loopback callback forge binds; it must be a redirect URI already registered on the IdP client. See Native OIDC |
optimizer.enabled |
bool (tri-state) |
Whether the forge surface offers the context optimizer for the Claude Code path. Off by default (unset). Enabling it rewrites ANTHROPIC_BASE_URL, which can't be forced under enterprise-managed Claude Code, so it's opt-in. Resolved from trusted layers only (user / project-local / CLI / managed) — a checked-in project .forge/settings.json cannot enable it. Highest layer wins, so a managed layer can force-enable or force-disable. |
optimizer.upstream |
string (URL) |
Base URL the optimizer proxy forwards to — typically the org's LLM gateway (e.g. a Kong/Bedrock endpoint). Consumed by forge optimizer / forge optimizer start (see CLI reference). Resolution precedence: managed settings > --upstream flag > $FORGE_OPTIMIZER_UPSTREAM > this (user) setting > $ANTHROPIC_BASE_URL chaining > Anthropic default. Like models.gateway, it redirects Claude Code's traffic + Authorization header, so it is read from trusted layers only (the checked-in project layer is excluded); a managed value overrides even --upstream (enterprise enforcement). Validated as an http(s) URL at startup. |
tools.builtins.enabled |
[]string |
Builtin tools offered/defaulted |
skills.enabled |
[]string |
Registry skills offered/defaulted in forge init (the wizard offers only these; non-interactive --skills is gated). Empty = all registry skills. Governs registry-skill selection only — NOT --from-skills / --from-skill-dir custom imports (a developer's own local skills). It is a developer-surface offering, not a hard skill boundary; to forbid skills fleet-wide, use platform policy |
env |
map[string]string |
Environment defaults |
forge settings # loaded layers (with their paths) + effective settings
forge settings show --json
forge settings show --settings ./ci-settings.json # add a CLI-precedence layerThe output lists each loaded layer lowest → highest, flags a managed available_models LOCK, and prints the merged effective settings. The default (human) view masks env values as *** (keys shown) since env may carry secrets; --json emits values verbatim for your own machine-readable dump — don't paste it into shared channels.
forge try: a configuredmodels.defaultseeds the provider/model when no flag is given;models.gatewayis injected into the scaffoldedforge.yamlmodel block (base_url/auth_scheme/auth_header_name);tools.builtins.enabled, when set, overrides the quickstart's default builtin set.forge init:models.gatewayis injected into the scaffoldedforge.yaml(both modes). In non-interactive mode,models.defaultseeds the provider/model andtools.builtins.enabled/skills.enabledseed builtins / skills when the corresponding flag is omitted — so a settings default even satisfies the otherwise-required--model-provider.channels.enabledandskills.enabledare enforced: non-interactive--channels/--skillsfail immediately on a non-enabled entry, and the interactive wizard offers only the enabled channels and skills (so a disabled one can't be picked; the end-of-wizard gate remains a backstop). Noteskills.enabledscopes to registry skills only —--from-skills/--from-skill-dircustom imports are the developer's own local skills and are not gated by it (a hard skill boundary is a platform policy concern, not this developer-surface setting). The interactive wizard also pre-selectsmodels.default: the provider step highlights the default provider, and the default model — but interactive model pre-selection applies only where a model-selector step exists (OpenAI today); providers with a fixed model default (e.g. anthropic, gemini) have no model step, so theirmodels.default.modelis not applied in the interactive wizard (the provider is still highlighted). The model default is always honored non-interactively (and via the scaffoldedforge.yamlgateway). The user still confirms each wizard choice; the gateway (no wizard step) is injected regardless.optimizer.enabled: consumed by the interactiveforgesurface. In the Claude Code path the optimizer option is disabled/unselectable unless a trusted layer setoptimizer.enabled: true— a checked-in project layer is excluded (same base-url-redirect trust boundary asmodels.gateway). A managed layer is the enterprise-approved enablement path.optimizer.upstream: consumed by theforge optimizerproxy (foreground and thestartdaemon) as the base URL it forwards to. It slots into the upstream precedence between$FORGE_OPTIMIZER_UPSTREAMand$ANTHROPIC_BASE_URLchaining; a managed value outranks even--upstreamso an org can pin the gateway. Read from trusted layers only (project layer excluded) because it redirects Claude Code's traffic and Authorization header. See CLI reference →forge optimizer.channels.enabledgating: when non-empty,channels.enabledis the allowlist of adapters that may run. It gates all three channel entry points:forge run --with <x>andforge channel serve <x>(the standalone runner) refuse to start a non-enabled adapter, andforge channel add <x>refuses to scaffold one. The gate runs before the policy deny filter. Empty = unconstrained (every registered adapter available). This is the positive enablement surface; policy remains the deny surface — settings decide "is it offered?", policy decides "is it forbidden?".
This is the model for forge on the server: a developer checks in a forge.yaml with a native provider + auth_scheme + API keys, and the deployed agent uses those. Local development is different — a developer testing on their laptop often must reach the model through the org's gateway (Kong/Bedrock) behind an IdP (Okta/Entra), which issues a short-lived token. Rather than editing (and risking committing) the native forge.yaml, managed or user settings auto-wire the call locally: at runtime, before building the model client, forge overlays the matching settings gateway onto the resolved model config.
Provider-scoped. The overlay picks the models.gateways entry whose provider matches the resolved model provider (a provider match wins; a provider-less models.gateway is the catch-all). If none matches — e.g. forge.yaml is openai but settings define only an anthropic gateway — there is no overlay and the run stays on native auth. So a laptop's settings can define one gateway per provider and each agent picks up only its own.
api_key_helper (the token). When the matched gateway sets api_key_helper, forge runs that external command (the Claude Code apiKeyHelper contract: it prints a token to stdout), caches the token by its JWT exp, and injects it as the model credential per the gateway's auth_scheme (bearer → Authorization: Bearer, native x-api-key suppressed, and for anthropic the anthropic-version header omitted — use this for a Kong OIDC route in front of Bedrock/Claude that validates a Bearer JWT; apikey_header → a named custom header). The command is run without a shell (argv is tokenized with quote-awareness; shell features belong in a wrapper script). The helper's own configuration (e.g. OKTA_CLIENT_ID, OKTA_ISSUER) goes in the gateway's env map, which forge injects into the helper subprocess — so nothing needs exporting before a run. env is applied on top of forge's own environment (a gateway value wins on a duplicate key). Because env parameterizes the token, the cache key includes it: one shared helper (e.g. a single okta.sh) reused across providers with a different OKTA_ISSUER per gateway correctly mints and caches a distinct token per gateway. env keys must look like environment variables ([A-Za-z_][A-Za-z0-9_]*) — an invalid key fails loudly rather than silently. The token is stored like every other credential: encrypted when FORGE_PASSPHRASE is set, else a 0600 file under ~/.forge/credentials; it is never logged. An opaque (non-JWT) helper token has no readable expiry, so it is treated as always-expired and the helper re-runs on every invocation — configure a helper that emits a JWT to benefit from caching.
Trust boundary — the gateway is resolved from trusted layers only. A gateway can name a command forge will exec (
api_key_helper) and can redirect the endpoint (base_url), so the checked-in project.forge/settings.jsonis deliberately excluded from gateway resolution — a cloned repo must not be able to run a command on your laptop or send your native provider key to an attacker's host. The gateway (andforge auth login) honor only the user, project-local (.forge/settings.local.json, gitignored), CLI (--settings), and managed layers. Every other setting (channels, skills,models.default,available_models) still honors the checked-in project layer as normal — only the gateway is trust-restricted.
Auto-login on expiry. The token is never sent stale: when the runtime overlay is about to inject the gateway credential and the cached token is missing or within its refresh buffer of expiry, it re-runs the api_key_helper to acquire a fresh one first. This holds for both managed and user layers, so an expired token triggers a re-login instead of a guaranteed 401. The managed login gate (root pre-run on forge run / try / serve) is the eager, fail-early variant — it logs in at command start before the runtime spins up; the overlay refresh is the universal safety net. You can still manage a user-layer token explicitly with forge auth login | logout | status. A browser-based helper is a local-dev affordance; headless servers use native auth and would not deploy one. Unlike the operator commands forge auth login/logout (which refuse to run inside a deployed agent runtime), the overlay refresh is allowed in a deployed runtime — a server legitimately re-acquires its own model credential from a non-interactive managed helper (e.g. client_credentials); an interactive helper there simply fails, and the command surfaces a warning and proceeds without the gateway token (the call then fails clearly rather than silently). The command comes only from trusted layers (the checked-in project .forge/settings.json is excluded), so this exec cannot be hijacked by a cloned repo.
available_models mismatch is a warning, not a block. If a resolved model isn't in a managed available_models lock, forge prints a one-line warning and proceeds with native auth — runtime model-deny remains a server-side platform policy concern (see Managed lock above), not a client-side rejection.
Instead of an api_key_helper external command, a gateway can set models.gateway(s).oidc and have forge run the OAuth/OIDC flow itself. Two grants:
client_credentials(headless / CI) — forge mints a token fromtoken_url(or theissuer-discovered endpoint) usingclient_id+ the secret inclient_secret_env, withscopes. No browser. The runtime overlay acquires and re-mints it on expiry automatically, so a deployed agent needs no interactive step.auth_code(interactive) —forge auth login [provider]opens a browser (auth-code + PKCE) againstauthorize_url(or theissuer-discovered endpoint), catches the loopback callback, and caches the token with its refresh token. The runtime overlay then refreshes it silently on expiry; it never opens a browser mid-run — if there's no valid cached token it tells you to runforge auth login.
"gateways": [
{ "provider": "openai", "base_url": "https://gw.corp/openai/v1", "auth_scheme": "bearer",
"oidc": { "grant": "client_credentials", "issuer": "https://idp.corp",
"client_id": "forge-gw", "client_secret_env": "FORGE_GW_CLIENT_SECRET",
"scopes": ["model.invoke"] } }
]The client secret is never stored in settings — client_secret_env names the env var holding it. The auth_code grant is a public PKCE client and needs no secret (it's the same flow as an Okta/Entra native app: client_id + issuer/endpoints + scopes + PKCE). Endpoints are taken from authorize_url/token_url, or discovered from issuer's /.well-known/openid-configuration (which must be https; loopback http allowed for a local dev IdP). The interactive auth_code flow binds the loopback callback in redirect_uri — set it to a redirect URI already registered on the IdP client (e.g. http://localhost:8199/oauth/callback); it defaults to http://localhost:1455/auth/callback. It must be an http loopback (localhost/127.0.0.1/[::1]). Like the helper, OIDC config is resolved from trusted layers only (the checked-in project .forge/settings.json is excluded).
Guardrail. The OIDC flow refuses to run against the anthropic public URL (
api.anthropic.com) — that API authenticates withx-api-key, not OAuth. A gateway fronting anthropic behind your own IdP (a different host) is fine.
Remaining follow-ups (tracked on the settings epic): additional managed delivery mechanisms (server-managed control-plane fetch, macOS config profile, Windows registry); per-fallback / per-endpoint gateway auth (today the overlay is primary-endpoint only); the device-code OIDC grant (client_credentials + auth_code ship now). The interactive-wizard defaulting (channel/skill filtering + provider/model pre-selection) is complete.
- Platform Policy — the deny surface (separate from settings)
- Authentication — outbound model
auth_scheme(referenced bymodels.gateway) - forge.yaml schema — the per-agent
modelblock settings inject into