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.
| Key | Type | Required | Notes |
|---|---|---|---|
name | string | yes | Must be unique across orgs. |
accounts | array of strings | yes | Each entry must match an [[accounts]] display_name. |
fallback_accounts | array of strings | no | Accounts the walk reaches after the pinned ones. Absent means none. |
browser_profile | string | no | The browser profile the org’s projects drive unless they override it. Absent is default. |
slack | table | no | Written 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. |
projects | array of tables | yes | Written 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 withhas 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]]
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
name | string | yes | Must be unique across all orgs, not just within one. | |
path | string | yes | A 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.
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
display_name | string | yes | Must be unique. This is the name orgs reference. | |
provider | string | yes | One of "anthropic", "codex", "openrouter", "zai". Decides how the account is probed and how its usage reads. | |
token | string | yes | The credential the gateway forwards, mapped onto the provider’s own variable at spawn. Trimmed once at load. | |
base_url | string | no | The 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. | |
models | list of strings | yes | The 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_slugs | table | no | {} | Canonical name -> upstream spelling, only where they differ. Every slug key must be in models, or the load fails (AccountSlugUndeclared). |
model_aliases | table | no | {} | 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). |
env | table | no | {} | 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.
| Key | Type | Required | Default | Notes |
|---|---|---|---|---|
auto_compact_window | integer | no | unset | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | true | false turns the automatic trigger off. /handoff still works by hand. |
at_percent | integer | 85 | The share of the CLI’s effective context window at which the handoff fires, from 1 to 100. |
after_compactions | integer | 1 | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
env | table | {} | 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_file | string | none | Path to a KEY=value file whose entries join this project’s env. |
max_workers | integer | 2 | Cap 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_mode | string | auto | Stamps 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. |
model | string | The 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_roots | array | [] | 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. |
handoff | table | none | Written 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_profile | string | the org’s | The 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. |
slack | table | the org’s | Written 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.
| Key | Type | Default | Notes |
|---|---|---|---|
port | integer | 8787 | The 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_count | integer | 5 | How many consecutive 429s from one account fire a rotation. 0 fails the load outright (GatewayRotationInvalid) - it would silently disable the streak. |
streak_window_secs | integer | 60 | The window the 429 streak is counted over, in seconds. 0 fails the load outright (GatewayRotationInvalid). |
no_reset_cooldown_secs | integer | 60 | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | Off 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_dir | string | platform cache dir | Where the model files live. ~ is expanded. |
transcribe_model | string | none | The 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_model | string | none | The same for the cleanup role. Inert while normalizer = false. |
language | string | autodetect | Spoken language hint. |
normalizer | boolean | true | Rewrite recognition output into clean text. Off halves the download and skips a pass per utterance. |
max_capture_minutes | integer | 30 | Upper bound on one recording. A capture reserves memory eagerly, about 110 MiB at the default. |
bind | string | right_cmd | The push-to-talk key: right_cmd, left_cmd or off. On Linux and Windows the cmd equivalent is the right/left Control key. |
mode | string | auto | How 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. |
styling | string | semi_formal | The 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. |
structure | string | prose | Whether the cleanup may return a bulleted list: prose or lists. |
context | string | general | What 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.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | false | Off unless asked for. Turning it on makes the speaking role available; the model installs from the models page or the settings card. |
models_dir | string | platform cache dir | Where the installed files live. ~ is expanded. |
speaking_model | string | hexgrad/Kokoro-82M | The 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. |
voice | string | af_heart | The voice every reply renders in. An id that is not installed is refused by name: voice <id> is not installed. |
max_utterance_seconds | integer | 120 | Upper 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.
| Key | Type | Required | Notes |
|---|---|---|---|
token | string | no | Sent 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.
| Key | Type | Required | Notes |
|---|---|---|---|
url | string | yes | Gotify server URL. |
client_token | string | yes | Token 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.
| Key | Type | Required | Notes |
|---|---|---|---|
enabled | boolean | no | Defaults to true. false keeps the section (and the key) without injecting the tools. |
base_url | string | yes | The endpoint’s base. forge appends /v1/systemone, or /systemone when the base already ends in /v1. |
api_key | string | no | Sent as a bearer token when set; omit it for keyless local hosts such as Ollama. |
model | string | yes | The model every call asks, e.g. jev-latest. |
timeout_ms | integer | no | Per-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.
| Key | Type | Required | Notes |
|---|---|---|---|
url | string | yes | The Vaultwarden base URL, e.g. https://vault.example.com. |
email | string | yes | The account’s email; it is the salt the master key derives with. |
idle_minutes | integer | no | Minutes 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.
| Key | Type | Default | Notes |
|---|---|---|---|
workspace | string | none | Label 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. |
token | string | none | User token, xoxp-.... |
poll_seconds | integer | 5 | Sweep interval for this workspace’s conversations, in seconds. |
dm_poll_seconds | integer | 150 | Sweep 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_days | integer | 14 | Drop 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_hours | integer | 24 | Mark 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.
| Key | Type | Default | Notes |
|---|---|---|---|
auto_update | boolean | false | Update 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 movementmark; 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.
| Key | Type | Default | Notes |
|---|---|---|---|
enabled | boolean | true | Whether the server starts with forge. false is the opt-out, and the boot is identical either way - the socket simply does not bind. |
port | integer | 8790 | The 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. |
bind | IP address | 127.0.0.1 | The 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_origins | array 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_kb | integer | 512 | How 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_kb | integer | 512 | How 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_kb | integer | 64 | How 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.
| Key | Type | Default | Notes |
|---|---|---|---|
fps | integer | 30 | Frames 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). |
quality | integer | 75 | Lossy 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_minutes | integer | 30 | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
shell | string | $SHELL | The 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_lines | integer | 2000 | The 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_minutes | integer | 0 | Minutes 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.
| Key | Type | Default | Notes |
|---|---|---|---|
mark | string | unset, drawing panes | The 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. |
theme | string | unset, drawing dark | The 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. |
font | string | unset, drawing Fira Code for prose and code alike | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
max_size_mib | integer | 10 | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
poll_seconds | integer | 60 | The 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.
| Key | Type | Default | Notes |
|---|---|---|---|
heal | boolean | true | Whether 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_attempts | integer | 3 | How 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_backoff | list 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_stable | duration | 10m | How 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 key | Takes effect |
|---|---|
[[orgs]] - name, accounts, fallback_accounts, browser_profile; [orgs.slack] workspace, [orgs.projects.slack] workspace | Live. 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, path | Live. 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_profile | Next 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_roots | Live. The next document read or hand-off consults the reloaded list. |
[[accounts]] - display_name, provider, base_url, token, models, model_slugs, model_aliases | Live. 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]] - env | Next 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_secs | Live. A reload that moved these replaces the rotation numbers; one that did not leaves the live cooldowns and streaks alone. |
[gateway] port | Restart-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_kb | Live 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, port | Restart-only. Stored and named in the log; forge never rebinds a live socket. |
[server] allowed_origins | Live. The next socket upgrade reads the new list; connections already open are untouched. |
[browser] idle_minutes | Live. The service reads the idle clock on every pass. |
[browser] fps, quality | Next session: applies to the next browser host. A host already running keeps the encoding it was created with. |
[terminal] shell, scrollback_lines | Next 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_minutes | Live. The idle clock reads it on every pass. |
[client] mark, theme, font | Live. 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_mib | Live. The next document read uses the new ceiling. |
[usage] poll_seconds | Live. 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, mode | Live. 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, context | Next 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_model | Restart-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_seconds | Restart-only. The voice service and the settings it renders with are built once at boot; the log names it. |
[huggingface] token | Live. Read at each install, so a save applies on the next press. |
[[slack]] workspace, token, poll_seconds, dm_poll_seconds | Restart-only. The client and its pump captured these when the subsystem started; the log names them. |
[[slack]] thread_idle_days, stale_after_hours | Live. thread_idle_days is read by each sweep and stale_after_hours when a block is drawn. |
[gotify] url, client_token | Restart-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_update | Not 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.