Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

forge.toml reference

forge.toml is the only file forge reads for configuration. It is hand-authored; two paths write it - the settings page’s save and the settings MCP tools (settings__index and settings__get read it, settings__set and settings__unset write it) - and both writers go through the same catalogue, validation and validated-temp-file writer, with an agent’s edit held for the reader’s approval before it applies. Nothing else in forge does. A change is picked up while forge runs: the server watches this one file, re-reads it whole, and applies what can take effect without a restart. A running session keeps the account it spawned on, its model, mode, cwd and child environment; its credential is resolved per request, so a re-minted token reaches it. Everything a subsystem reads per use takes the new value, and the table below says what waits for a restart.

Two directories in the config directory belong to forge: the skills it ships. The handoff skill lands at <config_dir>/skills/handoff/SKILL.md, and the wizard at <config_dir>/skills/wizard/SKILL.md, with the bash library its stages ride beside it at template.sh. Forge installs both at boot, compares every file byte for byte, and rewrites only what differs, so an unchanged copy is left untouched. Only a temporary file the installer itself left behind, or a name the skill retires, is ever removed; anything else you put in those directories is left alone. They are forge’s own; forge.toml stays yours.

Where it lives

<config_dir>/forge/forge.toml

<config_dir> is $CLAUDE_CONFIG_DIR when that variable is set to a non-empty value, and $HOME/.claude otherwise. If $CLAUDE_CONFIG_DIR is unset and the home directory cannot be resolved, forge refuses to launch rather than substituting a path derived from the directory it was launched from.

If the file is absent, forge exits with:

forge.toml not found at <path>; create it with at least one [[orgs]]
entry containing one [[orgs.projects]] entry

The shape

Two things are required: at least one org, and at least one account. Orgs hold projects. Accounts are the provider endpoints and credentials forge can spawn a session under. An org names the subset of accounts its projects are allowed to use.

Everything else is optional.

[[orgs]]

An array of tables. At least one is required, or the load fails with no [[orgs]] entries in forge.toml.

KeyTypeRequiredNotes
namestringyesMust be unique across orgs.
accountsarray of stringsyesEach entry must match an [[accounts]] display_name.
fallback_accountsarray of stringsnoAccounts the walk reaches after the pinned ones. Absent means none.
browser_profilestringnoThe browser profile the org’s projects drive unless they override it. Absent is default.
slacktablenoWritten as [orgs.slack] with workspace = "<label>": the [[slack]] every project in the org acts in. A project’s own binding overrides it. Absent means no project in the org has one unless it binds its own.
projectsarray of tablesyesWritten as [[orgs.projects]]. An org with none fails the load.

browser_profile names the browser every project in the org drives. The key is the org and the name joined - browser_profile = "work" under name = "Busytools" is the profile Busytools/work - and its cache and logins live under the server’s browser/profiles/Busytools/work/cef. A name is 1 to 64 characters of letters, digits, dot, dash or underscore, and a bad one fails the load naming the key and the value. A project’s own browser_profile keeps the same name inside its org and overrides the org’s.

accounts is the account subset every project in this org may spawn under. Rules enforced at load:

  • An empty list (accounts = []) fails with has an empty accounts = [] list.
  • A name that matches no [[accounts]] entry fails, and the error lists the valid names.

fallback_accounts is walked after the org’s accounts: a fallback declaring the project’s model outranks a saturated or bailed primary, and the primary is taken again once it heals. The names also carry as the fallback pin on the accounts subject. The list is validated at load exactly like accounts: a name matching no [[accounts]] entry fails the boot, naming the account and the valid names. An account may appear in both lists; it is then a primary only.

accounts and fallback_accounts must also agree on any env that changes the shape of a request, not only where it is sent. A child’s env is frozen when its session spawns and the gateway never respawns it, so after a rotation the next account is handed a request shaped for the account the session started on. The live example is CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: OpenRouter sets it and Zai leaves it unset, both declare glm-5.3-flash, and OpenRouter rejects the beta shape the flag suppresses. A project declaring glm-5.3-flash in an org holding both would spawn on Zai and then fail every rotation onto OpenRouter; the way out is to change one of the two declarations and start that session again.

[[orgs.projects]]

KeyTypeRequiredDefaultNotes
namestringyesMust be unique across all orgs, not just within one.
pathstringyesA leading ~/ is expanded to the home directory. The un-expanded string is kept for display.

Project names are the argument forge <PROJECT> takes, and the key per-project settings hang off.

[[accounts]]

An array of tables. At least one is required, or the load fails with no [[accounts]] entries in forge.toml.

KeyTypeRequiredDefaultNotes
display_namestringyesMust be unique. This is the name orgs reference.
providerstringyesOne of "anthropic", "codex", "openrouter", "zai". Decides how the account is probed and how its usage reads.
tokenstringyesThe credential the gateway forwards, mapped onto the provider’s own variable at spawn. Trimmed once at load.
base_urlstringnoThe upstream base for base-url providers ("codex", "openrouter", "zai"); required for them and validated per provider. An "anthropic" account may omit it - the gateway constant is its upstream.
modelslist of stringsyesThe canonical model names the account serves. Selection and the route’s model gate match against this list; an empty list fails the load (AccountModelsRequired).
model_slugstableno{}Canonical name -> upstream spelling, only where they differ. Every slug key must be in models, or the load fails (AccountSlugUndeclared).
model_aliasestableno{}Written as [accounts.model_aliases]. Canonical name -> the other names a request may arrive under for that model. Every key must be in models, or the load fails (AccountAliasUndeclared); an empty list fails (AccountAliasEmpty).
envtableno{}Written as [accounts.env]. Provider-behaviour extras only - timeouts, context caps, fallback switches. Gateway keys (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_API_KEY) declared here or in the global [env] layer are dropped at load and warned about: the flat base_url and token keys own them, and forge reads the account’s endpoint and credential from those.

All accounts share one claude config directory, so MCP servers, plugins and settings are declared once for every account; what varies per account is exactly the flat block above.

model_aliases covers the case where the name the gateway sees is not the name you configured. Long-context models are the usual reason: the claude CLI is handed claude-opus-5[1m], and the request body it then sends carries the plain claude-opus-5. Matching the body’s model against models alone would refuse that request, so the plain spelling is declared as an alias of the marker. A request naming any member of a model’s set - the declared name or any alias of it - matches the account, so one account serves both spellings. forge never interprets the names; an alias is matched as data.

provider has no default. Accounts that omit it are named together in one load error listing the accepted values, so a first run does not surface them one restart at a time. Silence is the dangerous answer here: a mislabelled account probes the wrong endpoint and then cannot reach a usable state, which stops forge starting.

The line has to sit above the account’s [accounts.env] table. TOML scopes every key after a table header into that table, so a provider written below one is read as an environment variable and the account still counts as missing it.

"anthropic" accounts may omit base_url: the gateway constant is their upstream. "codex", "openrouter" and "zai" require it, and an account declaring any of them without a base_url fails the load naming the account and the missing key.

For "openrouter" the base url must be the API root, https://openrouter.ai/api, and an account whose base does not end in /api fails the load. The bare host is the trap worth naming: forge probes {base}/v1/key, and https://openrouter.ai/v1/key answers 200 with a web page rather than a 404, so nothing downstream could tell it apart from a real reply.

The split is billing, not auth. "codex" is a base-url account whose proxy serves the same windowed body Anthropic does, so it reads as a subscription with rolling windows. "zai" is a subscription too: its usage is probed at the monitor host derived from the base url’s host root and read as rolling 5-hour and weekly credit windows. "openrouter" is pay-per-token: there is no window, so its usage is money spent over a period rather than a percentage of a plan. A key may carry a spending cap set from the provider’s dashboard, and where it does forge reads the spend against it as a percentage too; an uncapped key has no denominator, and forge says so rather than showing an empty bar.

Unknown keys in an [[accounts]] block are rejected, so a near-miss like providers fails the load instead of loading and doing nothing.

[env]

A flat table of string keys to string values, stamped onto every spawned claude subprocess. Absent means empty.

[env]
CLAUDE_CODE_AUTO_COMPACT_WINDOW = "950000"

[models."<name>"]

Optional per-model tables, keyed by the exact spelling a project’s model pins - that is the name the read matches on, not what accounts declare - so one entry serves every project pinned to that model. Absent means no parameters are stamped and the claude CLI’s own resolution stands. A table naming a model no project pins loads, but warns at boot: the entry is read by the projects’ pins, and no pin carries that name.

KeyTypeRequiredDefaultNotes
auto_compact_windowintegernounsetThe window, in tokens, the CLI’s auto-compaction triggers against.
[models."deepseek-v4.1-flash"]
auto_compact_window = 983040

auto_compact_window is stamped onto the spawned child as CLAUDE_CODE_AUTO_COMPACT_WINDOW. Set it below the model’s real context, leaving the compact query room to fit: compaction fires near a window’s top, and the compact request itself needs headroom beyond that for the completion budget and the prompt it carries - a window allowed to reach the model’s context can fire a compact request the context cannot hold, and the claude CLI exits with the session, which is what this key keeps out of reach. The value is yours to pick because forge does not know a model’s real context: leaving a sixteenth of the context as headroom (a 983040 window for a 1,048,576-token model) is measured to fit with room over. An env layer that names CLAUDE_CODE_AUTO_COMPACT_WINDOW itself replaces the table’s value.

A value that is not a positive integer fails the load: models "name" in forge.toml ... sets auto_compact_window = 0, which is not usable; it must name a positive window in tokens. Unknown keys inside a [models."<name>"] table are rejected, so a mistyped key fails the load instead of quietly applying nothing.

[handoff]

When a session’s context is nearly full, forge replaces it with a fresh session that continues from a brief: the work carries on, and the successor’s transcript opens with that brief, drawn as the handoff procedure. The trigger runs on by default, at the end of a turn, so a long session stops before its first compaction rather than after it.

KeyTypeDefaultNotes
enabledbooleantruefalse turns the automatic trigger off. /handoff still works by hand.
at_percentinteger85The share of the CLI’s effective context window at which the handoff fires, from 1 to 100.
after_compactionsinteger1The compaction count the backstop fires at, when a single turn crossed the window first.
[handoff]
enabled = true
at_percent = 85
after_compactions = 1

An absent table is exactly the defaults above. at_percent is a share rather than a token count because the denominator is the CLI’s own ContextUsage.max_tokens, which already reflects the model and any lowered auto_compact_window - 85% of a 200k window is 170k, and 85% of a 1M window is 870k, which is the same rule at a different limit.

The compaction count is a backstop, not the trigger: a turn that crosses the window before it ends compacts first, and the count is what hands that session over at the next turn boundary. A session that reaches two compactions is the alarm the handoff record exists for.

A project can carry its own table, and every key is optional - it sets the one it disagrees on and inherits the rest:

[[orgs.projects]]
name = "companies"
path = "~/Projects/companies"
# this project's sessions run unattended for days; let them finish
[orgs.projects.handoff]
enabled = false

A share outside 1..100 fails the load: the handoff table at the top level in forge.toml ... sets at_percent = 0, which is not usable; it must name a share of the context window between 1 and 100. So does after_compactions = 0, which would swap the seat on every turn. A project’s refusal names the project rather than the top level. Unknown keys inside a [handoff] table are rejected.

Per-project keys (inside [[orgs.projects]])

The per-project settings live directly on each [[orgs.projects]] entry, beside name and path. The entry rejects unknown fields, so a mistyped key fails the load loudly instead of quietly applying nothing.

KeyTypeDefaultNotes
envtable{}Written as [orgs.projects.env] directly under the entry, or inline as env = { ... }. The block form attaches to the MOST RECENT [[orgs.projects]] header - place it directly under its own entry, before the next header, or it lands on a different project.
env_filestringnonePath to a KEY=value file whose entries join this project’s env.
max_workersinteger2Cap on this project’s concurrently live dynamic workers. The count is per project: workers live in other projects neither consume this project’s budget nor raise its cap. A worker between heal attempts still holds its slot until its heal gives up. A spawn over the cap errors instead of queuing; despawning a worker frees its slot. Workers restored by the boot or lead-reconnect respawn of persisted rows are exempt, but still count toward the cap once live. 0 disables dynamic spawns for the project.
permission_modestringautoStamps the CLI’s permission mode onto every session this project spawns, overriding the session default. Absent means auto, not the session default, so a project’s sessions run one mode however its org’s accounts rotate.
modelstringThe project’s model. Fills the CLI’s model slots at spawn (ANTHROPIC_DEFAULT_HAIKU_MODEL, _OPUS_MODEL, _SONNET_MODEL, ANTHROPIC_SMALL_FAST_MODEL, CLAUDE_CODE_SUBAGENT_MODEL all carry it) and is the session default a /model change overrides. Must be declared by at least one account in the org, or the load fails - a model no account serves would otherwise stamp every slot and 503 each session’s first request. Required: the walk that picks a session’s account matches on the model an account serves, so a project without one is refused at spawn.
document_rootsarray[]Extra roots the document read accepts beyond the session’s own tree, so a project’s documents can live outside its repo (a vault, a shared spec directory). Each entry is an absolute path, ~ or a ~/ path; a relative one fails the load.
handofftablenoneWritten as [orgs.projects.handoff] under its own entry. Sets this project’s handoff trigger; every key is optional and falls back to the top-level [handoff] section above.
browser_profilestringthe org’sThe browser profile’s name inside this project’s org: a project setting work under Busytools drives Busytools/work. Absent keeps the org’s, and a bad name fails the load naming the key and the value.
slacktablethe org’sWritten as [orgs.projects.slack] with workspace = "<label>": the [[slack]] this project’s sessions act in. Absent keeps the org’s binding.

permission_mode stamps a permission mode onto every session the project spawns, overriding the launcher’s per-session default. A project that does not set the key gets auto stamped rather than the launcher default, so its sessions run one mode consistently however the org’s accounts rotate. The accepted values are the CLI’s mode names, "default", "acceptEdits", "plan", "dontAsk", "auto" and "bypassPermissions", plus the legacy aliases "ask", "deny", "accept_edits", "dont_ask" and "bypass_permissions"; anything else fails the load listing them. The mode the session actually runs is what the CLI reports back on connect, and the /mode picker offers bypassPermissions only on sessions launched into it; the CLI refuses a mid-session switch to bypass.

document_roots

[[orgs.projects]]
name = "companies"
path = "~/Projects/companies"
document_roots = ["~/Madness"]

Extra roots the document read accepts beyond the session’s own tree, so a project’s documents can live outside its repo. The documents__hand_off tool, the viewer’s document read and the tree review placement all consult the seat’s project’s roots; the file-index walk does not, because it lists the session’s tree.

The check canonicalizes both sides and resolves symlinks before comparing, so a symlink whose target exists and sits out of every root refuses like any other escape - a dangling one reports the document missing, exactly as it does in-tree - and a path inside a root reads exactly as an in-tree path does. A path outside the tree and outside every root keeps the refusal it always had: the path resolves outside the session's tree: <path>.

Each entry is an absolute path, ~ (your home directory) or a ~/ path. Anything else fails the load naming the key and the entry: project 'companies' in forge.toml at ... sets document_roots entry 'Madness', which is not absolute; every entry must be an absolute path, ~ or start with ~/, because a relative root would resolve against whatever directory the read happened in.

env_file

The file is parsed as KEY=value lines. Blank lines and lines starting with # are skipped. One matching pair of surrounding single or double quotes is stripped from the value; an unmatched quote stays part of the value.

Failures here are non-fatal and warn rather than refusing to boot:

  • A relative path is skipped entirely. Only absolute or ~/ paths are read, because a relative path would resolve differently depending on where forge was launched from.
  • A missing or unreadable file contributes no keys.
  • A line with no = is skipped; the rest of the file still applies.

The inline env table on the project entry wins over env_file per key.

[gateway]

The inference listener that spawns resolving to a project are pointed at.

KeyTypeDefaultNotes
portinteger8787The port the gateway’s listener binds on 127.0.0.1. There is no fallback to an OS-assigned port: if the port is taken, the gateway fails to start and preflight stays shut, because every session’s base URL names this port and a silent drift would point children at an address nothing serves. 0 fails the load outright (GatewayPortInvalid) for the same reason - it reads as “pick one for me”.
streak_countinteger5How many consecutive 429s from one account fire a rotation. 0 fails the load outright (GatewayRotationInvalid) - it would silently disable the streak.
streak_window_secsinteger60The window the 429 streak is counted over, in seconds. 0 fails the load outright (GatewayRotationInvalid).
no_reset_cooldown_secsinteger60The cooldown applied when neither the failing response nor the account’s own usage probe reports a reset time, in seconds. 0 fails the load outright (GatewayRotationInvalid).

Environment layering

Three layers merge per key, narrowest winning:

[env]  <  [accounts.env]  <  [[orgs.projects]] env

A project’s env block attaches to the most recent [[orgs.projects]] header - place it directly under its own entry.

The global and account layers merge at load. The project layer is applied at spawn rather than earlier, because one account serves many projects and merging sooner would leak one project’s keys into every other project on that account.

One carve-out applies to project spawns: after those layers compose, the gateway re-stamps four keys over the result - ANTHROPIC_BASE_URL (the listener with the session’s routing segments), the CLAUDE_CODE_API_BASE_URL slot, the account’s credential variable, and ANTHROPIC_API_KEY (forced empty) - so no layer can point a child away from the listener while it holds only the dummy credential.

One key is reserved by forge: CLAUDE_CONFIG_DIR. Setting it in any env layer overrides forge’s own stamp. The value still applies, since forge.toml is treated as trusted, but forge logs a warning naming the key.

A base-url account’s endpoint and credential are its flat base_url and token keys, mapped onto the CLI’s variable names at load. [accounts.env] carries only provider-behaviour extras - timeouts, context caps, fallback switches. A base-url or credential key declared in an env layer is dropped at load, so it can reach neither the child nor the pool the usage probe and the forward leg read: the flat keys own them. Setting ANTHROPIC_BASE_URL or ANTHROPIC_AUTH_TOKEN at the project layer instead desynchronises forge’s own accounting, because the usage probe, plan detection and the account pool all read the account map.

An "anthropic" account’s flat token - minted by claude setup-token - is its credential. The usage endpoint refuses setup tokens (they lack the user:profile scope), so a valid token is probed with a minimal billed messages call instead - its response headers carry the 5-hour and 7-day usage windows at roughly nine tokens per account per usage poll; a rejected token renders as an auth failure whose repair is a re-mint. A replaced token is read by the next probe and reaches a running session on its next request.

Only key names, never values, are recorded in forge’s per-spawn log line. These tables hold tokens.

Notifications

Notifications are the client’s own: a windowed client raises them through its host (a system banner, a dock badge, whatever the platform does), and there is no forge.toml key for any of it. The server raises nothing itself - it has no window to put a banner on.

On macOS the first alert after install raises the system’s notification permission prompt, and the OS drops that notification while the prompt is up. So the first ask from a fresh install is silent, and every later alert arrives normally.

A browser tab has no host process of its own, so the page asks the browser for its notification permission from the first click or keypress after an ask arrives - Firefox and Safari refuse a request made without one. A refusal is respected silently and never retried, and a page the browser offers no notifications to (an insecure origin, or a browser without the API) stays silent the same way. Either way the answer is outside forge: the browser’s own site settings for the address the client is served from, or that address being https or localhost.

There is no [ui] section either: the spinner and repaint keys the terminal drew with retired with the terminal crate, and no key replaced them. A forge.toml still carrying the section fails the load as an unknown section; see the list under “Unknown keys”.

[dictate]

Optional. Absent means dictation is off, which is also what an explicit enabled = false means.

KeyTypeDefaultNotes
enabledbooleanfalseOff unless asked for. Turning it on costs a 3.07 GB model download the first time and holds about 1.8 GB of resident memory for the run.
models_dirstringplatform cache dirWhere the model files live. ~ is expanded.
transcribe_modelstringnoneThe model the transcribing role runs, by the runtime’s own variant name. Set, it wins over every runtime pick and refuses an activation for that role; absent leaves the role to the last pick made on this machine, else the compiled default.
cleanup_modelstringnoneThe same for the cleanup role. Inert while normalizer = false.
languagestringautodetectSpoken language hint.
normalizerbooleantrueRewrite recognition output into clean text. Off halves the download and skips a pass per utterance.
max_capture_minutesinteger30Upper bound on one recording. A capture reserves memory eagerly, about 110 MiB at the default.
bindstringright_cmdThe push-to-talk key: right_cmd, left_cmd or off. On Linux and Windows the cmd equivalent is the right/left Control key.
modestringautoHow press/release maps onto recording: auto infers from timing (a quick tap toggles, a hold transcribes on release), toggle starts on a press and stops on the next press, hold records while held and always transcribes on release.
stylingstringsemi_formalThe register a take’s cleanup rewrites into: casual, semi_casual, semi_formal or formal. A client reads this as the value its panel starts on and resets to.
structurestringproseWhether the cleanup may return a bulleted list: prose or lists.
contextstringgeneralWhat the words are being written for: general or email. email may return multi-line text.

An unrecognised key here fails the load rather than being ignored: a mistyped models_dir would otherwise fetch three gigabytes to the wrong volume with nothing said about it. device is the one exception - a declared ghost: the microphone is always the reader’s, so the key is warned about and ignored rather than obeyed, and a config still setting it keeps loading.

The models live where forge runs; the microphone is wherever the reader is: a client captures on its own machine and streams the audio to forge, where the pipeline recognises it. styling, structure and context are defaults rather than state: they reach every client in the greeting, and a client that captures holds the values in force itself, per seat, so its panel’s reset row returns to these.

With enabled = true, forge fetches, verifies and loads the models on the preflight screen before forge hands over. A first run downloads 3.07 GB, resumable and SHA-256 verified; a later run re-hashes only a file that has changed since its digest was recorded, about a second for the pair, and checks an unchanged one with a stat. Then the weights load. Pressing esc during a download keeps what has landed and quits.

A role’s model is resolved fresh each boot, in this order: the transcribe_model / cleanup_model key when set, else the last runtime pick made on this machine (kept in the state store beside forge’s other machine-local state, not here), else the compiled default for this binary. Setting a key never clears the pick, so removing the key returns the role to the last runtime choice rather than to the compiled model.

A key names a variant, not a path (granite-speech-5.0-470m-turboctc): this file syncs across machines and a path is per-machine. The variant resolves through the machine’s installed models first - a local file, no network - and then through the runtime’s catalogue, whose per-model doc carries the download URL. A variant that resolves through neither fails the boot, naming the key and the variant:

[dictate] transcribe_model names granite-470m, which is neither an installed model nor a catalogue entry

A variant that resolves but is not on disk yet is fetched by the boot preflight, the way the compiled defaults already are, and records as installed.

A key pins the role: the runtime cannot change it, and the models page refuses an activation for it, naming the key:

[dictate] transcribe_model pins this model in forge.toml; remove the key to change it here

Downloads and benchmarks stay allowed on a pinned role - a pinned role can still pull candidates down and bench them; only the running model cannot change without editing this file.

The compiled model files themselves are not configurable. Each carries a URL, a byte length and a digest, and a hand-edited one is a file nothing can verify. A model named by the keys above, or downloaded from the models page, is checked against the feed’s own byte length and the engine’s own load instead: those files publish no digest, and nothing forge prints calls one verified.

[tts]

Optional. Absent means the speaking role is off, which is also what an explicit enabled = false means. It is the other direction of the voice area [dictate] opens: a take becomes text, and a reply becomes speech.

KeyTypeDefaultNotes
enabledbooleanfalseOff unless asked for. Turning it on makes the speaking role available; the model installs from the models page or the settings card.
models_dirstringplatform cache dirWhere the installed files live. ~ is expanded.
speaking_modelstringhexgrad/Kokoro-82MThe model the role runs, or one of its ONNX variants (kokoro-82m-fp32, kokoro-82m-fp16, kokoro-82m-int8). An unknown name fails the load: [tts] speaking_model names <name>, which is not a known model.
voicestringaf_heartThe voice every reply renders in. An id that is not installed is refused by name: voice <id> is not installed.
max_utterance_secondsinteger120Upper bound on one voice__say’s text, not on one sentence.

An unrecognised key here fails the load, as one in [dictate] does: a mistyped models_dir would otherwise leave the engine looking in a directory that has no model, and the failure would surface at the boot prewarm rather than when the key is saved. language is the one exception - a declared ghost: the voice id is the language choice, and the model’s 54 voices cover eight languages, so there is nothing for the key to set. It is warned about and ignored rather than obeyed, and a config still setting it keeps loading.

The speaking model is Kokoro-82M, published by hexgrad as hexgrad/Kokoro-82M. forge installs the ONNX export that onnx-community/Kokoro-82M-v1.0-ONNX publishes, pinned to the revision the install reads: one ONNX file plus the style files for the model’s 54 named voices. The weights are Apache-2.0. The compiled default is the int8 export (kokoro-82m-int8). The local benchmark scores a model over the bundled public corpus for round-trip word error, first audio, real time factor and the peak resident set above its own baseline; the models page draws the figures a run leaves.

Rendering is in process: forge runs the ONNX session through ort and the G2P pipeline through Misaki with its bundled espeak-ng. Kokoro renders a whole clip rather than streaming inside one, so a say is split into sentences and each sentence’s audio arrives when it finishes rendering. The output is 24 kHz mono 16-bit, Kokoro’s own rate, and a model whose rate differed would be resampled once in the engine.

The model installs from the models page or the settings page’s speaking card. Every file is fetched at one pinned revision and checked against the LFS sha256 the Hub publishes; a digest that disagrees leaves nothing behind. The engine checks the whole set again before it loads it, so a truncated or substituted file is refused by name. The engine loads once at boot when [tts] is on and stays resident for the process’s life; a failed load is retried by the first say.

[huggingface]

Optional. The token a gated repository needs.

KeyTypeRequiredNotes
tokenstringnoSent as a bearer token on every Hub read the speaking model’s install makes. The speaking model’s own repository is open, so an unset token changes nothing; the key exists for a gated repository.

An unrecognised key here fails the load, as one in [tts] does.

[gotify]

Optional. Absent means the Gotify integration stays dormant.

KeyTypeRequiredNotes
urlstringyesGotify server URL.
client_tokenstringyesToken for the receive stream and the application lookup.

Both are mandatory once the section is present; neither has a default.

[systemone]

Optional. Absent or enabled = false means the decision tools (systemone__ask_noul, systemone__ask_choice, systemone__ask_score) are not injected into any session.

KeyTypeRequiredNotes
enabledbooleannoDefaults to true. false keeps the section (and the key) without injecting the tools.
base_urlstringyesThe endpoint’s base. forge appends /v1/systemone, or /systemone when the base already ends in /v1.
api_keystringnoSent as a bearer token when set; omit it for keyless local hosts such as Ollama.
modelstringyesThe model every call asks, e.g. jev-latest.
timeout_msintegernoPer-request timeout in milliseconds; defaults to 30000.

base_url and model are mandatory once the section is enabled; a missing one refuses the load naming the key - the message reads [systemone] `base_url` is required when `[systemone]` is enabled (or the same sentence for model) - and timeout_ms = 0 refuses with [systemone] `timeout_ms` must be greater than zero. A key the endpoint itself rejects surfaces as a tool error naming api_key; forge never substitutes another credential.

What the endpoint takes: up to 255 options in a choice question, 2 to 10 levels in a score question, and a whole request - state, instructions and the question together - inside the live context, which OpenRouter lists as 32000 tokens for these models. Upstream describes the shape as 32k of state under a 64k request; the live 32000 is what to size against, with the state kept well inside it. It serves about 100K tokens/s at 80 requests/s and bills $0.042 per million input tokens, rates upstream notes can change without notice; the default 30s timeout_ms has ample headroom.

[vault]

Optional. Absent means the vault family (vault__search, vault__get, vault__create, vault__update, vault__trash, vault__restore, vault__totp_code, vault__attachment_get, vault__attachment_put and the four vault__folder_* tools) is not injected into any session. The family is also gated per agent through mcp_families, like every other toggleable family. Adding the section does not put the family into sessions that are already running: a session’s tool catalogue is fixed when it spawns, so a live session gains the tools only from a new spawn.

KeyTypeRequiredNotes
urlstringyesThe Vaultwarden base URL, e.g. https://vault.example.com.
emailstringyesThe account’s email; it is the salt the master key derives with.
idle_minutesintegernoMinutes without a vault call after which the session locks. Omitted means no idle lock.

The two account keys are mandatory once the section is present. A missing one refuses the load naming the key: [vault] `url` is required when `[vault]` is present (or the same sentence for email). Setting the section from a session means sending both keys in one settings__set call: a single-key write leaves a half section the loader refuses, and that refusal comes back before the reader is asked to approve anything. An unknown key in the table refuses the load like every other section, and url must be an https:// URL: an http:// one refuses the load with [vault] `url` must be an https:// URL; http is refused. There is no built-in default, because forge.toml is the one source of truth for the account.

idle_minutes is optional and omitted means no idle lock, which is what forge did before the key existed. A number of minutes locks the shared session on the first vault read after that much time without one, and the next vault call asks for the master password or a device gesture again; 0 refuses the load with [vault] `idle_minutes` must be at least 1; omit it for no idle lock, because a zero bound would lock instantly and no one means that. The settings page’s vault card draws the state and offers the manual lock, whichever way the session was locked.

One session serves every agent and every session. It holds the derived keys in memory only, zeroized, and never stores the master password: the first vault call after a forge start parks a card whose masked field carries the password over the local answer channel, and one unlock serves every card parked behind it. A call the server rejects with 401 tries the refresh token, and only a failed refresh locks the session and makes the next held call ask again; idle_minutes adds the unused-session lock above. A reload that changes this section rebuilds the session, which locks it; a reload that leaves the section alone keeps it unlocked.

Every vault operation is held for the reader’s approval and appends one line to <app-support>/vault/audit-<config-dir-store-key>.jsonl - when, who, which tool, which item and field, the decision and the outcome, never a value. The file rotates at 10 MB into .1 through .6, so the six most recent chunks and the current file are kept.

[[slack]]

Optional, and repeatable: one entry per Slack workspace. Absent or empty means the Slack connector stays dormant.

KeyTypeDefaultNotes
workspacestringnoneLabel for this workspace, distinct per entry. An org or project binds to it with workspace = "<label>"; the Slack tools then act in it with no argument from the caller.
tokenstringnoneUser token, xoxp-....
poll_secondsinteger5Sweep interval for this workspace’s conversations, in seconds.
dm_poll_secondsinteger150Sweep interval for the DM class, in seconds. It covers every DM in the workspace rather than a list, so it costs one history call per DM per pass whatever the subscription count, and it is swept on its own slower clock.
thread_idle_daysinteger14Drop a followed thread with nothing new for this many days. A thread that quiet is resolved in practice, whatever its parent’s age.
stale_after_hoursinteger24Mark a delivered block’s header with its newest message’s age once that message is at or past this age. Zero turns the mark off; nothing is dropped.

workspace and token are mandatory once an entry is present. Three mistakes fail the load rather than booting a connector that cannot work: an empty workspace, an empty token, and two entries sharing a workspace label. An unknown key inside an entry is rejected, so a near-miss fails loudly.

A workspace becomes a project’s workspace only when a scope binds it:

[[orgs]]
name = "Work"
accounts = ["Work"]

  # Every project in the org acts in this workspace.
  [orgs.slack]
  workspace = "Work Slack"

  [[orgs.projects]]
  name = "service"
  path = "~/Projects/service"

  # This project acts in a different one.
  [orgs.projects.slack]
  workspace = "Service Alerts"

Each [orgs.projects.slack] block attaches to the MOST RECENT [[orgs.projects]] header, like every other nested table there. A project’s binding wins over its org’s; a project with neither leaves the Slack tools refusing with the key to add, not asking for an argument.

A binding that names no [[slack]] label fails the load, naming the label it asked for and the ones that exist:

forge.toml at <path> is otherwise invalid: [[orgs]] slack: no [[slack]] workspace named 'acme'; configured: Service Alerts, Work Slack
forge.toml at <path> is otherwise invalid: [[orgs.projects]] slack: no [[slack]] workspace named 'acme'; configured: Service Alerts, Work Slack

A session whose project binds nothing (and whose org binds nothing) answers every Slack tool with the keys to add:

no Slack workspace is bound to Work/service; add [orgs.slack] workspace = "<label>" to the org, or [orgs.projects.slack] workspace = "<label>" to the project in forge.toml

A binding to a [[slack]] entry added since the last start has no token loaded yet, and names the way out instead of pretending the key is missing:

the [[slack]] workspace 'Work Slack' is bound but not live; a [[slack]] entry added or renamed since boot needs a forge restart

There is no default binding: a project that names nothing is left unbound rather than picking up the only configured workspace, because two orgs may share one Slack install and an implicit choice would post as the wrong one.

A binding governs the tools, not delivery: a stored subscription keeps delivering into the project that created it, whatever the project later binds, because the record is the explicit statement of where those messages go. So removing a binding stops a project acting in that workspace without silencing what it already watches, and rebinding a project leaves the previous workspace’s subscriptions delivering: slack__list shows only the newly bound workspace’s records, while the old ones stay listed in the Inspector’s Slack section for the project, where their ids are the handle slack__unsubscribe takes.

The token is a credential, so it lives here rather than in the state store beside the subscriptions. forge proves it with auth.test at boot and logs the team and user it resolves to, or the failure; a workspace whose token fails keeps its conversation sweeps running but its mention stream stays down - the sweeps cannot recognise <@U...> without it - and forge retries the proof in the background until it succeeds.

[plugins]

Optional. Absent means plugin auto-update is off, which is also what an explicit auto_update = false means. Manual updates from the plugins pane (u, or the per-plugin Update action) are not affected by this section.

KeyTypeDefaultNotes
auto_updatebooleanfalseUpdate every installed plugin once at forge boot. The switch alone governs. Off unless asked for: an auto-applied update can break a load-bearing session mid-day.

When the run fires, the plugins pane reports what updated, from which marketplace, and what it skipped; forge remembers the previous version so the plugin’s actions overlay can offer “Roll back to previous version” afterwards. Rollback needs the recorded pre-update marketplace ref, which in turn needs the marketplace to be git-backed, and the ref to still be fetchable; a rollback that does not actually move the plugin to the recorded version keeps the record so it can be retried.

Like [dictate], an unrecognised key here fails the load rather than being ignored. Keys an older forge read here (trusted_marketplaces, pins) are rejected the same way: remove them.

The task board’s chase

Fixed behaviour, not configuration. There is no [tasks] section: forge.toml refuses one - and any key like stale_hours - as an unknown key, and the chase has no switch: the sweep runs every 30 seconds, always. What it asks, and on what clock, is a constant in the code:

  • The no-movement window is 4 hours. A row with no update for that long draws the no movement mark; a ready row with no owner, and a dependency or resource wait, ask the lead every 30 minutes once they pass it. The estimate is what arms the chase for a row that has one; this is the fallback for rows that have none. A wait parked by design carries neither mark and draws no stall ask; un-parking restores both from its age again.
  • A row past its estimate asks its owner once, and a restated estimate arms the nudge again; a wait that resolved asks its waiter once to resume it.
  • A row past 1.5x its estimate tells the lead every 5 minutes while it stays over. The escalation is authority: rescope, reassign, take over, or restate the expectation.
  • A failed row, one whose owner is gone, an epic still owing its retro, and one ready to close each tell the lead every 30 minutes while the state holds.
  • A worker holding no row is named when it drifts, again every 5 minutes while it stays drifted, and at once after it accounts and drifts again.
  • Each row waiting on the user, untouched for 30 minutes, gets one nudge of its own per wait episode - one message per row, naming the row’s ask, and never a repeat while that wait holds. A row the lead acts on (attested, withdrawn or parked by design) leaves the set, and a row that leaves waiting and comes back is a new episode with a new nudge. A tick that finds every seat asleep spends nothing: the nudge waits for a live lead.

[server]

The socket: one WebSocket served from the process that already owns the sessions, so a client costs a listener rather than a second scheduler. On by default, so a restart leaves it serving.

KeyTypeDefaultNotes
enabledbooleantrueWhether the server starts with forge. false is the opt-out, and the boot is identical either way - the socket simply does not bind.
portinteger8790The port the server binds on bind, and a client is pointed at it by hand. 0 fails the load outright (ServerPortInvalid), and so does the gateway’s own port (ServerPortTakenByGateway) - two listeners cannot share one. Neither check runs while the socket is disabled: a stale port on a section that never binds cannot stop the boot.
bindIP address127.0.0.1The interface the server listens on. Loopback by default: reaching it from another machine means naming that machine’s interface here, usually the WireGuard address. forge is reached over loopback or a private network and is never exposed publicly. That is the whole reason the socket carries no authentication - the private network is what the access control stands on, not a proxy to be added in front of a public bind.
allowed_originsarray of strings[]Browser origins allowed to open the socket, beside the shell’s own, the server’s own address (http://<bind>:<port>, with the loopback aliases), and any page served on the user’s own machine - the localhost name or any loopback IP address, on either web scheme and any port, in every build (a dev client is the case). A web client served from another address has its page origin listed here; any other origin is refused at the upgrade before the greeting, and a request that sends no Origin at all is a native client and is allowed. The authority is read as a host and an optional numeric port, so a lookalike such as localhost.evil.com or [::1].evil.com stays refused. Live: the next upgrade reads the new list.
first_frame_cap_kbinteger512How many kilobytes of conversation a session’s first snapshot carries. The newest whole turns are kept while they fit and the rest is paged through more, so a switch paints the page it is handed at once and the rest is available through more as the reader or the column asks; nothing is dropped and the order does not move. The newest turn is served even when it alone is over the budget, because a page of no turns draws the seat empty and half a turn is one a client cannot place. The page also stops at twenty turns whatever the budget. 0 serves the twenty turns whole, and a value that is not a whole number fails the load, naming the line and the key with the value redacted. Over loopback the socket write and the client’s fold measure about 1 ms and 2 ms per megabyte, so this key is about the network: measured through a rate-limited socket, a 5.5 MB first page reaches the client in about 0.66 s on 100 Mbit/s and 2.7 s on 20, against 0.16 s and 0.6 s once the 512 KB budget has cut it to 1.16 MB.
more_page_cap_kbinteger512How many kilobytes of conversation one more page carries. The first snapshot has had a byte budget since first_frame_cap_kb; a more answer was cut by its turn count alone, and the page contract is whole turns, so one large turn crossed whole and a single page could be tens of megabytes. The newest whole turns are kept while they fit, the newest turn is served even when it alone is over the budget, and the page’s cursor names the history above it as it always did - nothing is dropped and the walk above the page still reaches every turn. A page the budget cuts is logged at DEBUG as page_cut_by_budget, naming the seat, the key the budget came from (more_page_cap_kb or first_frame_cap_kb), the budget, the turns kept and the turns available. 0 serves the turns the client asked for, whole.
tool_record_cap_kbinteger64How large a frame’s tool_use_result may be in anything a client is served, in kilobytes. A record over it is reduced first, keeping only the keys a client draws (a mutation’s patch and outside-edit mark, a question’s answers and notes); when those alone are still over, it keeps the one key the row’s drawing needs - a question’s answers or an edit’s structuredPatch - so a question whose typed notes pushed it over keeps its answer and draws as the card it was. Only when nothing drawable fits does it become an omitted marker. Every path to a client is bounded - a page, a more, the live stream and both repairs - while the transcript keeps the record whole and the tool-result block draws as always. Every record a served copy bounds is logged at DEBUG as tool_record_capped, naming the seat, the tool call, the record’s original size, the keys it kept and its disposition (reduced or omitted), so a reader asking where a payload went can find the exact call. The line is per capped copy, not per record: a subscribe caps the snapshot and its newest page separately, and a live frame is capped once per reading client, so the same call can log more than once in one open - the call’s own id is what ties the lines together. 0 serves the records whole - the setting for a reader whose changes carry patches beyond the default. A seat whose mailbox tool results ran to ~4.9 MB each served a 70 MB page that took the client ~6.7 s to settle; the cap is what makes a page’s size follow its conversation rather than its tool payloads.

The server starts no subsystem of its own - no cron scheduler, no connector, no second gateway - so the cost of a client is the listener and the messages. The socket carries the whole view surface: the home and every session, a snapshot of each on subscribe and every update after it, with a client’s commands going back the other way. No page is served from here. A client is its own program and draws everything itself; the socket hands it data. A bind that fails is fatal on the headless binary: with no view of its own it would serve nothing.

[browser]

The server’s browser: how often a host encodes frames, how good they are, and how long a profile nobody uses stays up. Absent means every default.

KeyTypeDefaultNotes
fpsinteger30Frames encoded per second, between 1 and 120. CEF paints at its own cadence and faster paints are coalesced to this rate before the encoder runs, so the key bounds the encode cost a watching viewer causes. A paused host - one with no viewer attached - encodes nothing whatever this is. A value outside the range fails the load ([browser] fps must be 1 to 120).
qualityinteger75Lossy WebP quality for the frames between key frames, 0 to 100. A key frame is lossless regardless: the first frame of a surface and every frame after a resize or a pause. A value over 100 fails the load ([browser] quality must be 0 to 100).
idle_minutesinteger30The idle release, on the server’s clock: after this many minutes with no browser call and no viewer attached, the profile’s host is closed. The close ends the host and its tabs; the cache and logins stay, and the next call starts a fresh host whose boot tab the first page call adopts. A viewer attached or a hand-off waiting on the person holds the profile open - the sweep skips both rather than closing a page someone is looking at. A lower does not reset the clock; the next call or attach does. The clocks live in the server’s memory, so a restart grants every browser a fresh window.

[terminal]

The terminal one per seat: the shell it runs, how much scrollback the model keeps, and when an idle one closes. Absent means every default.

KeyTypeDefaultNotes
shellstring$SHELLThe shell a seat’s terminal runs. An absent key uses the person’s own $SHELL, falling back to /bin/zsh on macOS and /bin/sh elsewhere. zsh, bash and fish get forge’s OSC 133 integration, so commands and their exit codes are exact; another shell starts with no integration and the classifier says heuristic. An empty value fails the load ([terminal] shell must name a shell; remove the key to use $SHELL).
scrollback_linesinteger2000The scrollback the server’s screen model keeps, in lines. 0 keeps none. What terminal__read reads is bounded by this, so a seat that needs a deeper history raises it; the client’s own xterm buffer is separate and bounded at 2,000.
idle_minutesinteger0Minutes an untouched, unattached terminal may sit before the idle clock closes it. 0 is off, which is the default: a seat’s terminal lives as long as the seat. A terminal with a client attached or a hand-off waiting on the person is never closed by the clock.

[client]

Which of the sets forge ships a client draws with. Absent means every name unset, which is the built-in.

KeyTypeDefaultNotes
markstringunset, drawing panesThe mark a client draws, by name: panes, klin, lanes, f_slab, split, spine, grid, clamp, strike, nest, chamfer, tally or stencil_f. A fixed set rather than a file path, so every option is one forge has drawn.
themestringunset, drawing darkThe palette, by name. dark is the only one shipped so far; a second is its own pass, since every state colour needs a treatment that stays legible on the new background.
fontstringunset, drawing Fira Code for prose and code alikeThe typeface, by name. Fira Code is vendored, bundled with the client and licensed OFL 1.1; system is the opt-out to the stacks the OS already has. --ui and --mono come from here rather than from the stylesheet, so this is the only place a typeface is chosen.

[documents]

The ceiling on one document a session hands the reader through the documents__hand_off tool, and on one file’s content at a revision the review page reads for a between-hunk expansion (file_content): both read under this key. Absent means the default.

KeyTypeDefaultNotes
max_size_mibinteger10The largest document the viewer opens, in MiB, between 1 and 190. A read over it is refused naming the size, before any bytes are read. The review page’s file_content read shares the ceiling and answers a flagged short read at it rather than a refusal, and caps its answer at 10,000 lines besides - which is what bounds a file of very short lines before the bytes do. 0 fails the load: a ceiling of nothing would refuse every document, which reads as a bug rather than a choice. A value past 190 fails the load too: a document crosses the socket base64-encoded, and a file read crosses as JSON lines whose escaping can cost about twice their bytes, both measured against the desktop client’s frame cap of 256 MiB - a document at this ceiling stays inside it, and the file read’s own 10,000-line bound is what keeps the ordinary case well inside it too.

A row’s motion is not configurable. The client animates its own: the marks move by state - the working mark turns in 900ms and the starting one in 1500ms, and that difference is how a reader tells them apart.

An unset name is not an error and not a fallback: it means the built-in. A name forge does not ship fails the load with the key and the value in the message (ClientNameUnknown, client mark = "anvilish" ... names nothing forge ships; this key picks by name, never by path), because a setting that is quietly ignored reads as the key not working. Like the port checks, none of them run while the socket is disabled.

mark, theme and font are a client’s settings rather than the server’s, and the socket carries them to a client on connect, so a client never reads forge.toml for itself and the file stays the one source of truth. The server draws none of them: the socket serves no page.

[usage]

How often the background poller refreshes account usage. Absent means the default.

KeyTypeDefaultNotes
poll_secondsinteger60The refresh interval, in seconds. The session page draws its 5h/7d utilization from the cache this fills, and the gateway’s account selection reads it too, so the interval is an upper bound on how stale a “which account has more headroom” decision can be. 0 fails the load: a zero interval would probe continuously.

[sessions]

What happens when a durable seat - a project’s own agent, or a worker with a stored row - loses its subprocess while it is supposed to be up. Absent means the defaults, which bring the seat back by itself.

KeyTypeDefaultNotes
healbooleantrueWhether forge restarts a dead durable seat at all. false starts no new attempt: a death leaves the seat failed with its diagnostic, the way a failure always did, while an attempt already scheduled still runs.
heal_attemptsinteger3How many restarts one run gets before the seat is left Failed with the last diagnostic and its project’s agent is told. The minimum is 1; 0 fails the load (sessions.heal_attempts = 0 ... is not usable; it must name at least one restart), because healing would be on with nothing to attempt.
heal_backofflist of durations["5s", "30s", "2m"]How long forge waits before each restart, in order. A list shorter than heal_attempts repeats its last interval; an empty list fails the load. A duration is digits and one unit - ms, s, m or h - and a bare number fails the load naming the key.
heal_stableduration10mHow long a restarted seat must stay up before its death count resets, so a seat that dies every few minutes still runs out of its restarts.

A durable seat is a project’s own agent or a worker with a stored row. An ad-hoc session has no row of its own, so opening its seat is what wakes it. A despawn, a close and a forge shutdown never heal: the stop owns the seat, and the boot re-spawn owns the return after a restart. /exit typed into a durable seat does heal - a durable seat is up until it is despawned.

A worker death that heal answers is delivered to its project’s agent as one peer message, whatever the attempt then does: the time in IST with UTC in parentheses, the diagnostic, the attempt and its outcome. A death the stop paths own - a despawn, a close, a shutdown, an ad-hoc seat - sends nothing. A delivery to a sleeping agent parks and lands when it wakes, like any peer message. A lead has no agent above it, so its project’s row draws restarting (N of M) while it is between attempts, then the seat’s own live state once it is back, or the failure with its diagnostic at the cap.

heal_attempts, heal_backoff and heal_stable are read at each death, so an edit applies to the next seat that dies.

Unknown keys

Every table rejects unknown fields, so a mistyped key fails the load and names itself rather than parsing clean and meaning something else - a misspelled fallback_accounts would otherwise read as “no fallbacks”. That covers the top level, [[orgs]], [[orgs.projects]], [[accounts]], [models."<name>"], [handoff] and [orgs.projects.handoff], [[slack]], [gotify], [systemone], [gateway], [dictate], [tts], [huggingface], [plugins], [server], [browser], [terminal], [client] and [sessions].

Two retired keys are declared ghosts rather than unknown keys, so a stale forge.toml still boots and warns instead of failing: [dictate] device and [tts] language.

Every other retired key and section is refused like any unknown key, naming itself as the load fails: resume_lead, [web], [workers], [projects.<name>], [selection] and [ui]. [web]’s listener keys (enabled, port, bind) are [server]’s and its drawing keys (mark, theme, font) are [client]’s; forge carries no alias for the old name.

A complete example

# Applies to every spawned session, unless a narrower layer overrides
# the same key.
[env]
CLAUDE_CODE_AUTO_COMPACT_WINDOW = "950000"

[[orgs]]
name = "Personal"
accounts = ["Personal", "Scratch"]

  [[orgs.projects]]
  name = "forge"
  path = "~/Projects/forge"

  [[orgs.projects]]
  name = "notes"
  path = "~/Projects/notes"

[[orgs]]
name = "Work"
accounts = ["Work"]

  # Every project in the org acts in this Slack workspace; a project's
  # own [orgs.projects.slack] overrides it.
  [orgs.slack]
  workspace = "acme"

  [[orgs.projects]]
  name = "service"
  path = "~/Projects/service"
  max_workers = 4
  env_file = "~/.config/service/secrets.env"

  [orgs.projects.env]
  SERVICE_MCP_URL = "https://mcp.example/service"

  # This project's sessions run unattended for days; finish the work
  # rather than swapping the session under it.
  [orgs.projects.handoff]
  enabled = false

# The automatic session handoff: on at 85% of the window, with the first
# compaction as the backstop.
[handoff]
at_percent = 85

[[accounts]]
display_name = "Personal"
token = "personal-setup-token"
models = ["claude-opus-5", "claude-sonnet-5"]
provider = "anthropic"

[[accounts]]
display_name = "Work"
token = "work-setup-token"
models = ["claude-opus-5", "claude-sonnet-5"]
provider = "anthropic"

# Talks to a local endpoint: the flat base_url and token keys carry
# the endpoint and credential; [accounts.env] stays for
# provider-behaviour extras only.
[[accounts]]
display_name = "Scratch"
provider = "codex"
base_url = "http://localhost:18765"
token = "scratch-token"
models = ["claude-sonnet-5"]

  [accounts.env]
  CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS = "1"

[dictate]
enabled = true
normalizer = true
max_capture_minutes = 30

[gotify]
url = "https://gotify.example"
client_token = "CxxxxxxxxxxxxxxxA"

[[slack]]
workspace = "acme"
token = "xoxp-xxxxxxxxxxxx"
poll_seconds = 5
dm_poll_seconds = 150
thread_idle_days = 14
stale_after_hours = 24

[plugins]
auto_update = true

[server]
enabled = true
port = 8790
bind = "127.0.0.1"

[client]
mark = "panes"
theme = "dark"

[documents]
max_size_mib = 10

Reloading an edit

forge watches <config_dir>/forge/forge.toml itself, never the directory: nothing else in that tree can trigger a reload. An atomic save (a temporary file renamed over the path) and a sync tool’s incoming change both count, because the watch is re-armed on the path after every event. About half a second after a burst of writes the server reads the whole file and validates it.

A file that does not parse is rejected whole: one warning names the path and the error, and the last good config stays live. No partial application. Once a file is accepted, the config forge holds is the file; the table below says which parts a running forge adopts and which wait for a restart.

Each key carries one of three classes, and the settings page draws the same class the reload reports:

  • live - read on the key’s next use.
  • next session - read when a session or a connection opens.
  • restart - built once at boot; an edit needs a restart.

A fourth display state, not read yet, marks a key the loader accepts and nothing consumes. The catalogue in crates/forge-workspace/src/settings.rs is the one source for all four, and a test fails when a key the loader accepts has no class.

Table or keyTakes effect
[[orgs]] - name, accounts, fallback_accounts, browser_profile; [orgs.slack] workspace, [orgs.projects.slack] workspaceLive. The roster, pins and the next tool call read the new values. A Slack binding to a label already in [[slack]] applies to the next tool call.
[[orgs.projects]] - name, pathLive. The roster re-reads them; a removed project leaves it, and sessions already running keep running.
[[orgs.projects]] - model, permission_mode, max_workers, env, env_file, browser_profileNext session. The next spawn reads them; a running session keeps what it spawned with. A project added by an edit starts when you open it; an edit does not start anything by itself.
[[orgs.projects]] - document_rootsLive. The next document read or hand-off consults the reloaded list.
[[accounts]] - display_name, provider, base_url, token, models, model_slugs, model_aliasesLive. The pool gains or changes accounts, and retires a removed one: sessions bound to it keep working, new selections never see it, and usage probes follow the new set. A re-minted token reaches a bound session on its next request.
[[accounts]] - envNext session. Folded into each account’s env at load, so the next spawn carries it; a running child keeps its own environment.
[env]Next session. Folded into each account’s env at load, so the next spawn carries it.
[models."<name>"]Next session. The next spawn stamps the new parameters.
[handoff], [orgs.projects.handoff]Live. The next handoff reads it.
[systemone]Next session. A session’s MCP facade captures the client when it spawns, so a session already running keeps the tools it had.
[vault]Live. A reload that changes the section rebuilds the process-wide session, which locks it; a reload that leaves the section alone keeps it unlocked.
[gateway] streak_count, streak_window_secs, no_reset_cooldown_secsLive. A reload that moved these replaces the rotation numbers; one that did not leaves the live cooldowns and streaks alone.
[gateway] portRestart-only. Stored and named in the log; forge does not move a live listener.
[server] tool_record_cap_kb, first_frame_cap_kb, more_page_cap_kbLive for pages and more: a page read takes the new value at its next read, on a connection already open included. A live frame keeps the cap its connection read when it opened, so a reload reaches the next connection’s stream rather than an open one’s.
[server] enabled, bind, portRestart-only. Stored and named in the log; forge never rebinds a live socket.
[server] allowed_originsLive. The next socket upgrade reads the new list; connections already open are untouched.
[browser] idle_minutesLive. The service reads the idle clock on every pass.
[browser] fps, qualityNext session: applies to the next browser host. A host already running keeps the encoding it was created with.
[terminal] shell, scrollback_linesNext session: applies to the next terminal a seat opens; a terminal already running keeps the shell and screen model it was built with.
[terminal] idle_minutesLive. The idle clock reads it on every pass.
[client] mark, theme, fontLive. The server pushes the whole effective config on every applied reload, and an open client re-themes, re-fonts and re-marks itself from it without a reconnect.
[documents] max_size_mibLive. The next document read uses the new ceiling.
[usage] poll_secondsLive. The poller adopts the new interval on its next tick.
[sessions]Live. Each death reads the policy, so an edit applies to the next seat that dies.
[dictate] bind, modeLive. The server reads them on every key decision; a client’s key handler holds the composer record it last read, so a new occupant or reconnect refreshes what it starts from.
[dictate] styling, structure, contextNext session: applies to the next connection, which is when a client takes its capture axes.
[dictate] enabled, models_dir, language, normalizer, max_capture_minutes, transcribe_model, cleanup_modelRestart-only. The preflight and the engine’s own configuration are built once at boot; the log names it.
[tts] enabled, models_dir, speaking_model, voice, max_utterance_secondsRestart-only. The voice service and the settings it renders with are built once at boot; the log names it.
[huggingface] tokenLive. Read at each install, so a save applies on the next press.
[[slack]] workspace, token, poll_seconds, dm_poll_secondsRestart-only. The client and its pump captured these when the subsystem started; the log names them.
[[slack]] thread_idle_days, stale_after_hoursLive. thread_idle_days is read by each sweep and stale_after_hours when a block is drawn.
[gotify] url, client_tokenRestart-only. The gotify__* tools read the section on every call, so a new url or token reaches the tools at once; the stream pump captured its connection when it started and keeps it until a restart. A change is stored and the log names it.
[plugins] auto_updateNot read yet. The loader accepts the key and nothing consumes it; the settings page labels it rather than hiding it, and a change applies nothing.

A running session is never reconfigured. Its account (except when the gateway’s own rotation moves it off an exhausted one), model, permission mode, cwd and child environment stay what they spawned with; the credential itself is resolved per request, so a re-minted token for that same account applies at once. To pick up a changed variable or account, close that session and start it again.

What forge does at startup

forge <PROJECT> opens the named project. The name must match a project’s name exactly.

Preflight runs first on every route. It resolves every [[accounts]] entry and, when [dictate] is on, fetches and loads the dictation models, then hands over to wherever you were headed: the project picker for forge, straight into that project’s chat for forge <PROJECT>. It is shown once per run.

When [dictate] is on, boot also reads the cached model catalogue and refreshes it in the background - fetching the feed the dictation-models page reads - when the cache is older than a day or absent. That refresh never holds the boot path: it lands as an update on the page’s own subscription, and a forge.toml with dictation off fetches nothing.

Nothing spawns until every account has settled, because the walk that picks a session’s account reads each one’s state.

A bailed account does not stop forge from starting. Ready and Bailed both count as settled, so preflight completes: the row names the failure and the pollers keep re-probing it. The walk still keeps a bailed account as a last resort, and picks one when nothing else in the pin declares the project’s model - which is why the project row stays clickable. Fix that account’s auth, or remove its [[accounts]] block; the edit applies to new work, and an auth repair reaches a running session on its next request. The screen names both.

On a launch with no project named, every project whose lead was left open still spawns its lead session in the background, resuming the session forge stored for it unless the launch passed --new, but none of them is focused: you pick one from the picker to enter chat. A project closed from the pane stays shut, and a project whose lead never spawned has nothing to reopen.

The first launch on a build that stores this state seeds every existing lead row as open, because rows written before it carry no state. That one launch therefore reopens every project whose lead had ever spawned, including a project that had been closed by an earlier build; from then on the state is explicit. A lead whose last turn was cut off is handed one restart note as its first turn, telling it to read the project handoff and the task board and to continue where it left off; a lead that was parked is resumed with nothing, and a --new launch sends no note at all.

What was open is therefore what is warm when you arrive; it is not what you land on.

The socket comes up on [server] bind:port during the same boot, unless [server] enabled is false.

Config versus state

forge.toml is the config half. forge re-reads it on a change, and its writers - the settings page’s save and the settings MCP tools - go through one path: a temp file in the same directory, validated by this loader, renamed over the path, with an agent’s edit held for the reader’s approval first. Nothing else in forge writes it, which keeps it safe to sync between machines.

Everything mutable lives in one embedded redb database per config dir, at <app-support>/stores/<hash-of-config-dir>.redb (a SHA-256 of the config dir’s path, so a second config dir on the machine owns its own state; the single-instance lock keys on a separate digest): durable crons and the schedules their fired prompts came from (the pairing a reloaded chat row titles itself by), Gotify subscriptions, Slack subscriptions, the sweep watermarks and the own-post ledger beside them, the session identity per project and label - the lead’s and every worker’s, which is what brings a worker back after a restart - review threads, the per-account usage cache, cached model pricing, and the usage scan’s per-file token summaries.

Dictation keeps four things outside the database, all machine-local and never synced: each take’s audio and transcripts, as plain files under <app-support>/dictate-diagnostics/ - voice recordings outside the database - the read-aloud set the models page records under <app-support>/dictate-read-aloud/, which is the one corpus a bench can score on words that are known, the record of verified model digests under <app-support>/dictate-digests/, which is what lets a boot over unchanged models skip re-hashing them, and the last fetched model catalogue under <app-support>/dictate-catalogue/, which is what lets the models page read the feed when the network is gone.

<app-support> is ~/Library/Application Support/forge on macOS and $XDG_DATA_HOME/forge on Linux.

The single-instance lock lives under the same base, at <app-support>/locks/<hash>.lock, where the hash is derived from the config directory path.

Neither belongs in a synced config directory. The database churns constantly and redb’s binary file cannot be merged by a file syncer. The lock is worse: flock binds to the inode rather than the path, so a sync tool that replaces the file by rename would swap the lock out from under a running forge on another machine, and a second instance could then start against the same config directory.

One forge process owns one config directory. A second is normally refused at boot, naming the holder’s PID when it can read one. The guard is best-effort: if the lockfile cannot be created or locked, forge warns and starts anyway. See Single instance per config directory.