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

What forge is

forge is a Rust workspace that drives Anthropic’s claude CLI. It is three things sharing a repository:

  • A server for many sessions. One forge process holds many claude sessions across several projects and several accounts. It runs headless and serves one WebSocket, on 127.0.0.1 by default, so reaching it from another machine is a [server] bind line rather than a second forge.
  • An SDK for the claude CLI. forge-sdk spawns the binary and speaks its stream-json protocol over stdio: the codec, the transport, control-request dispatch, and an in-process MCP host.
  • A client that draws it. The app under client/ - a Svelte page inside a Rust shell - connects to the socket and renders everything: the fleet, per-session chat, a projects board, the inspector, tasks and reviews. A client subscribes to a subject - the home, a session, the token/cost pool - is answered with that subject in full and then with the updates that change it (usage is the exception: it is a scan, and is answered once per subscribe), and acts through the same command bus. The socket page is the protocol, and the full set of surfaces the client can draw is catalogued in the client’s pages, which are kept in step with the code.

forge never calls the Anthropic API itself. It spawns claude and talks to it, so the CLI stays the thing that runs the agent loop.

It is not a port of the Python SDK

forge-sdk and Anthropic’s Python claude-agent-sdk wrap the same binary. That is the whole of the relationship. They share a wire contract with claude and nothing else: no shared API shape, no parity target, no matching method names.

The practical consequence is that forge-sdk’s public surface is whatever serves the rest of this workspace, and it changes when that need changes. If you are looking for a drop-in Rust equivalent of the Python SDK, this is not that.

What is fixed is the wire. What forge writes to the CLI’s stdin and reads from its stdout has to be what claude expects, and a difference there is a bug. The wire contract page covers how that is enforced.

Scope, honestly

forge was written for one person’s use across a few machines. That shows in places: development is macOS-first, the security model assumes a single trusted user, and the configuration file is hand-authored rather than managed through the UI. It is open source because the code may be useful to read or build on, not because it has been generalised for arbitrary deployments.

One behaviour is worth knowing before you run it:

  • forge takes an exclusive lock on a config directory, so a second forge on the same config directory is normally refused at boot. The guard is best-effort and warns rather than failing if it cannot be established.

API reference

Every crate in the workspace is published as rustdoc at the API reference, private items included. It is the place to go when you are reading or changing the code rather than running it.

Where to go next

Install and build

Prerequisites

  • Rust nightly, at the pinned date. rust-toolchain.toml pins an exact nightly channel plus the rustfmt, clippy and rust-src components. With rustup installed, the pin is applied automatically the first time you run a cargo command inside the repository, so there is nothing to select by hand.

  • The claude CLI. forge spawns it; it does not bundle it or talk to the Anthropic API on its own.

  • cargo-nextest, for the test suite. just check uses it rather than cargo test.

  • mdbook, for the book. CI pins 0.5.4; rule 11’s workflow verifies a UI change with mdbook serve docs/book.

  • just, if you want the task recipes. Most wrap a cargo invocation you can also type out.

  • Node and npm, for the client. just check runs the client’s Prettier, ESLint, svelte-check, tsc and vitest steps, and its android warnings check (client-android-warnings, the shell crate compiled for aarch64-linux-android), and just client-tauri-check builds the shell in its shipping configuration; just release builds and installs it. CI uses Node 24, and npm --prefix client ci fetches the client’s dependencies. The client also builds for Android, which additionally wants the Android SDK, its NDK and a JDK 17 or newer; the recipe is in client/README.md.

  • A C and C++ toolchain, cmake, and libclang. forge-workspace depends on forge-dictate, which builds two native model runtimes (transcribe-cpp-sys, llama-cpp-sys-2), and on forge-tts, whose Kokoro engine compiles bundled espeak-ng through CMake - so these are needed to build the binary at all, not only to run --workspace. cmake and a C compiler were already required before that, through aws-lc-sys; the C++ compiler and libclang (for bindgen) are what the speech runtimes add.

    On macOS: Xcode Command Line Tools cover the compilers and libclang, and cmake comes from Homebrew. On Debian and Ubuntu: build-essential cmake libclang-dev.

  • ALSA headers, on Linux only. cpal’s Linux backend links against ALSA. On Debian and Ubuntu that is libasound2-dev; macOS needs no audio package.

About the nightly pin

The pin exists so every developer and CI build use the same compiler, and CI enforces it rather than trusting it: the toolchain action reads the channel out of rust-toolchain.toml, installs it, and then asserts that the toolchain rustup will actually select matches the pin. That guards against a RUSTUP_TOOLCHAIN in the environment or a rustup override on the runner quietly moving CI to a different compiler while the file still reads as authoritative.

Bump it deliberately, in its own change. No crate in the workspace opts into an unstable language feature with #![feature(...)], so the pin buys reproducibility rather than access to any particular nightly feature. The published rustdoc is the one exception: it needs -Z unstable-options --enable-index-page, which is nightly-only.

Development is macOS-first

forge is developed and run on macOS. Other platforms compile, and CI builds and tests on Linux - with two jobs on macOS. One runs the workspace clippy there, so the cfg(target_os = "macos") arms the Linux clippy job compiles out are still linted; just check runs that same clippy on whatever platform you run it on, so a macOS checkout lints them locally too. The other compiles the two macOS-only halves no Linux runner sees, the server’s browser host and the shell’s native overlay, and runs the host’s own tests - but one thing behaves differently:

  • App-level chords use Cmd on macOS, Ctrl elsewhere. The pane toggles (Cmd+Left/Cmd+Right vs Ctrl+Left/Ctrl+Right) and toggle-all-tool-calls (Cmd+X vs Ctrl+X) swap their modifier.

Build and check

just check

That runs four independent groups concurrently: the fast checks (cargo fmt --check, the Unicode punctuation gate, the book’s client-page word-count gate, and the scripts’ own tests, script-tests: the release manifest’s, the client-dependency bootstrap’s, the client-quit script’s, the check-group orchestrator’s, the gate lock’s, the book’s client-page word-count test’s, the client-wasm check’s, the browser-stack patch’s and the browser-host focus test’s), the Rust workspace (cargo clippy --all-targets --workspace -- -D warnings once per feature set, with and without --all-features, cargo nextest run --workspace --all-features, cargo test --doc --workspace --all-features and cargo doc --workspace --no-deps --all-features --document-private-items), the client web steps (the client’s Prettier check, ESLint, svelte-check, tsc --noEmit and then its vitest run), and the shell crate’s steps (its rustfmt check client-tauri-fmt, its clippy and nextest run client-tauri-clippy and client-tauri-test, and its android warnings check client-android-warnings: cargo check --target aarch64-linux-android with warnings denied). The clippy, test, doctest and doc steps each set RUSTFLAGS=-D warnings so a warning CI would reject fails locally too; CI sets it once at workflow level instead. It covers the build each step drives, and rustdoc compiles a doctest on its own, so it does not deny warnings inside one.

FORGE_CHECK_JOBS caps how many of the four groups run at once; it defaults to 4, so a machine with fewer cores sets it lower (FORGE_CHECK_JOBS=2 just check). Every group runs to its own first failure, each failing group’s log tail is printed, and the verdict names the first failing step in the gate’s order, so a concurrent group cannot hide one. The one verdict with no step behind it is the gate lock’s: [ERROR] check: the gate lock was not acquired (held by pid N), printed when another gate on the machine held the lock past the wait bound, before any step runs. Each run’s per-step logs stay in their own target/check-logs/run.XXXXXX/ directory, printed as logs: ... before the verdict. just check-serial is the same gate one step at a time, for debugging a step or running on a machine that should not carry the groups together.

When sccache is on PATH, just exports RUSTC_WRAPPER=sccache, so compiled objects are shared across worktrees and branches; without it the recipes build normally. It is never required.

The gate is two commands: run this one before opening a pull request, and just check-feature-configs with it. just check runs with --all-features, so it deliberately leaves out the configuration install.sh builds; the second command is what compiles it. Beyond that this is CI’s set with four differences: CI also runs cargo check --release, the client’s production build with scripts/check_client_wasm.py (the wasm the highlighter imports, which just check never builds), and scripts/install.sh followed by a boot of the installed binary with no library path (the shared transcribe/ggml libraries have to resolve from beside it, and neither local command installs anything), while client-android-warnings (the shell’s android step) is in just check alone - the vendoring its build needs refuses non-macOS hosts, so CI runs no such job yet (issue #1959, closed as an enhancement and tracked in #2258). The client’s steps are in here too, so the client and the server are not separate commands, and a failure on either side lands on the same verdict line.

CI runs each build and test job in ci.yml only for the paths it serves, so a docs-only pull request does not wait for the Rust or client jobs. The macOS clippy job serves the same paths as the Linux one, because it lints the same source on the second platform. The client’s CEF job serves every crate in forge-server’s dependency graph - forge-browser, forge-server, forge-workspace, forge-agent, forge-gateway, forge-connectors, forge-dictate, forge-tts, forge-terminal, forge-capture, forge-system-one, forge-supervise, forge-sdk and forge-primitives - and the inputs that resolve or build them: Cargo.lock, the root Cargo.toml, rust-toolchain.toml, clippy.toml, scripts/ and the nextest config. One job, ci-required, always runs, depends on every job in that workflow, and fails when a job that ran failed or when a job whose paths changed was skipped. The ruleset requires two checks: ci-required there, and build site from the docs workflow, which runs on every pull request. A skipped job means its paths did not move, never that the gate passed without looking.

The shell under client/src-tauri/ is its own workspace root, so the workspace’s cargo steps do not reach it; in CI its clippy and tests are the client-tauri job’s, and its rustfmt check the cargo fmt --check job’s second invocation. just check reaches it through client-tauri-fmt (the shell crate’s own cargo fmt --check), the crate’s own clippy and tests (client-tauri-clippy and client-tauri-test) and client-android-warnings - the crate compiled for aarch64-linux-android with warnings denied, which is what catches desktop-only code going dead on Android - and the Unicode punctuation gate and the client’s Prettier step reach it too. The shell’s shipping configuration needs just client-tauri-check (which runs the tests too) or just client-tauri-bundle (plus the bundles), and the first is the one to run before handing over a change there.

A client bundles with just client-tauri-bundle, and carries no browser: the engine, its driver and every profile live on the SERVER. The install (scripts/install.sh) is what stages the browser there - a signed Forge Browser.app beside forge’s machine-local state, and the driver stack (node and @playwright/mcp, about 140 MB) beside it. The vendoring needs network access the first time and nothing after that, and prints the pins it verified on every run. The server’s browser is macOS-only: the install stages the bundle only on Darwin, and elsewhere its browser tools answer the no-host refusal by name. No browser needs installing: a client built where the server has no engine answers the browser doors with the server’s own sentence rather than a timeout.

The run ends on a verdict line naming its own result: [OK] check: ..., [ERROR] check: <step> failed, [ERROR] check: group reported nothing (<group>) when a group died without a report, or [ERROR] check: the gate lock was not acquired (held by pid N) when another gate on the machine held the lock past the wait bound. A failing group stops at its own first failure; the other groups still run; every failing group’s log tail is printed before the verdict; and when a step fails, the verdict names it and the later steps in that group that did not run. The lock verdict is the one with no step behind it: it arrives before any step runs. That line is deliberately the last one written: piping the output through tail reports tail’s exit status rather than the recipe’s, so a red gate reads green to whoever ran it unless the result travels in the text.

Individual pieces, if you want a faster loop:

just fmt              # rewrite files to match rustfmt
just clippy           # lints only
just test-all         # whole workspace, all features
just test             # forge-sdk only
just conformance      # replay every committed wire baseline
just doctest          # compile the workspace's doctests
just doc              # rustdoc with warnings denied
just check-delta <crate>   # fmt, clippy and nextest for the named crates
just check-serial     # the whole gate, one step at a time
just client-tauri-clippy   # the shell crate's lints
just client-tauri-test     # the shell crate's tests
just client-tauri-check    # the client shell, in the shipping configuration
just client-android-check  # the Kotlin half and its unit tests
just web-image-check       # the web image's poller, entrypoint and serving tests

just check-delta <crate>... is the fast slice for a review round: each named workspace crate’s rustfmt check, both clippy feature sets and its nextest run, the Unicode gate, and the client’s steps or the shell crate’s clippy and tests when client or client-tauri is named. It is not the pre-merge gate: the final head still runs the full just check and just check-feature-configs whole.

just check-release compiles the workspace in release mode. It is deliberately not part of just check, because a second full compile is too slow for the inner loop; it catches the errors only a release build sees.

just check-feature-configs compiles the configurations nothing else builds: the one just install produces - release, the test-only features off, the server binary plus the host executable install.sh builds separately - and testing on its own. CI goes one step further and installs the server binary of that configuration, booting it with no library path to prove the shared transcribe/ggml libraries resolve beside it; the local recipe stops at compiling. --all-features cannot stand in for either, because it turns every feature on regardless and so never exercises which configuration enables what: the install build leaving test-helpers off, or testing having to forward it.

just release gates on both.

Install the binary

just install

This builds forge from the current working tree and installs the binary into cargo’s install root, which is ~/.cargo/bin/forge unless you have overridden CARGO_INSTALL_ROOT or CARGO_HOME. It builds whatever branch is checked out; it does not clone, fetch or reset.

It also ships transcribe’s shared libraries beside the binary. The 0.4 ggml builds as a shared library, because its static copy duplicates llama-cpp-2’s symbols in one Linux binary, and the loader does not search the executable’s own directory: forge carries a loader path naming that directory, and the libtranscribe and libggml* files land beside it. No environment variable is involved. A hand-rolled cargo install copies only the binary and leaves one that cannot start.

The install builds into a staging root and replaces the installed binary only once the staged copy starts with its libraries beside it; a failure before the replacement leaves the previous binary untouched.

One side effect worth knowing about: it regenerates the zsh completion at ~/.zsh/completions/_forge (override the directory with FORGE_ZSH_COMPLETION_DIR) and removes ~/.zcompdump* so the next shell picks it up. On macOS it also stages the browser host and its driver; FORGE_INSTALL_BROWSER=0 skips that stage.

just release <version> installs this binary too, and publishes the release. It bumps the workspace and the client’s manifest to the same number, commits and tags them, then installs the server binary through this same script, builds and installs the client over /Applications/forge.app, and stages the Android release APK and the web archive. Only then does it push main with the tag and create the GitHub release with every asset the update path reads; the push and the publish are last because they are the only irreversible steps. The binary goes first, so a failure later in the sequence - a client that does not quit when the release asks it to, a missing Android keystore or toolchain - cannot leave it stale. The install replaces the on-disk binary; a running forge picks the new one up at its next restart. A failure after the tag aborts the release with the tag cut; each half re-runs alone with the tree still at the tag (just install, just client-release <version>, just client-android-release <version>, just client-web-release <version>), and a release whose tag was cut and pushed finishes with just publish <version>, while re-running just release refuses because the tag exists.

First run

forge needs a forge.toml before it can start. Create <config_dir>/forge/forge.toml with at least one org containing one project, and at least one account. The configuration reference has a complete example.

forge              # reopen the projects left open
forge <PROJECT>    # open a specific project by its forge.toml name

Useful flags:

FlagEffect
--newStart every boot session fresh instead of resuming. Only affects the startup wave.
--log-file <PATH>Write tracing diagnostics to a file.
--log-filter <FILTER>Tracing filter directives, for example info,bridge.sdk=trace. Overrides --diagnostics-preset, and falls back to RUST_LOG when omitted.
--diagnostics-preset <NAME>A named logging preset: runtime, session, bridge or full. Ignored when --log-filter is given.

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.

Architecture

Fifteen crates, layered so the dependency graph stays acyclic.

forge-primitives      leaf: pure data, no logic, no I/O, no async
forge-capture         leaf: microphone capture, depends on no forge-* crate
forge-dictate     ->  capture
forge-tts             leaf: speech synthesis, depends on no forge-* crate
forge-system-one      leaf: decision-model client, depends on no forge-* crate
forge-terminal        leaf: the PTY and screen model, depends on no forge-* crate
forge-supervise       leaf: subsystem supervision, depends on no forge-* crate
forge-gateway     ->  primitives
forge-connectors  ->  primitives
forge-sdk         ->  primitives
forge-agent       ->  primitives + sdk + gateway
forge-workspace   ->  primitives + agent + sdk + dictate + tts + system-one + terminal + gateway + connectors
forge-browser     ->  primitives + sdk
forge-server      ->  primitives + workspace + sdk + browser
forge-test-harness->  primitives + sdk
CrateWhat it owns
forge-primitivesEvery type that crosses a crate boundary: message envelopes, content blocks, hook and permission payloads, IDs, render-side view structs. No logic, no I/O, no async.
forge-captureMicrophone capture: the input device, the input plan, the downmix and resampler, and the mono model-rate audio a host reads from it. A leaf: it depends on no forge-* crate and knows nothing about a host, a take or a wire, so a client’s own shell captures through it and forge-dictate takes its sample rate.
forge-dictateThe dictation primitive: audio in, text out. Owns its model files, speech recognition and transcript normalization, and takes audio as mono frames a host pushes in. Its one forge-* dependency is the capture leaf, for the sample rate, and it knows nothing about the program embedding it.
forge-ttsThe speech primitive: text in, audio chunks out. Owns the Kokoro-82M engine (ONNX Runtime in process, with Misaki and its bundled espeak-ng for G2P), the model files a speaking model is made of, sentence splitting and the utterance bound. A leaf: it depends on no forge-* crate and knows nothing about a seat, a tool or a wire.
forge-system-oneThe System One decision-model client: the [systemone] config shape, the wire question/answer types and their validation, the HTTP call with its timeout and error mapping. A leaf: depends on no forge-* crate, and its types stay there for consumers to import.
forge-terminalOne terminal per seat: the PTY, the screen model and the read loop, the OSC 133 scanner and the shell integration scripts, the text extractor, the attach repaint, and the state classifier. alacritty_terminal is its only terminal dependency; the event loop is forge’s own because the client’s stream is the raw bytes and that loop consumes them. A leaf: it depends on no forge-* crate and knows nothing about a seat, a socket or a session - the workspace keys it by the slot, the server carries its bytes, the client draws them.
forge-superviseSupervision for forge’s long-lived background tasks: one registry that starts a member’s future, restarts a death with bounded backoff, refuses to resurrect a deliberate stop, and answers a snapshot of what is running. A leaf: it depends on no forge-* crate, and a member’s own loop stays in the crate that owns it.
forge-gatewayThe account pool: one backend per provider token (credential resolution, the usage probe’s HTTP and payload mapping, billing shape), plus account selection by declared models, account health, probe scheduling and backoff. Depends on forge-primitives only; the claude --version user agent and TLS-trust plumbing arrive through the host port forge-agent implements.
forge-connectorsOne module per external integration: the Gotify and Slack connectors and the outbound Vaultwarden client behind the vault family. Depends on forge-primitives only; Gotify’s workspace state and message dispatch arrive through the host port forge-workspace implements, and the vault’s session, tokens and unlocked account live there.
forge-sdkThe claude subprocess. Stream-json codec, transport, control dispatch, the in-process MCP host, and the options builder.
forge-agentDrives one SDK client behind a channel-based Agent and AgentHandle. Owns user-data reads, cloud calls, environment probes, event translation and tooling. Async, may shell out.
forge-workspaceThe multi-session orchestrator and the server’s single point of contact. Owns forge.toml loading, DomainSession, per-session actors, the machine-local state store, the in-process MCP server forge exposes to every spawned session, and the output-tail reader a call’s own read answers from. The browser tools it carries reach the fleet through the BrowserHost port this crate defines.
forge-browserThe server’s browser. One CEF host process per org profile, shipped as its own signed Forge Browser.app so the server never links CEF; the host owns that profile’s tabs and paints each one into a WebP stream on the profile’s own local socket, multiplexed by tab; the vendored Playwright driver it speaks MCP to, tab-addressed; the org profile store under profiles/<org>/<name> and its one-time legacy-tree deletion; the idle release; and the service that keeps each session’s current tab and answers the tool surface and every viewer call. Depends on primitives and sdk, and nothing above it knows CEF exists.
forge-serverThe server: what a client reads of the core, and no view of its own. The read surface a client uses, the session records as a view sees them, the peer envelope parsing, forge’s own slash commands, the transcript fold that says where a conversation’s turns sit and the task endings it carries, the sub-agent instance list the session task’s own fold keeps and pushes, the call-output read the socket answers from its held conversation (with the workspace’s tail reader re-exported for views), and the socket that carries all of it. It also ships the forge binary - the whole core booted through the one boot function (forge_server::boot). Nothing here draws, so another client attaches beside the one that exists rather than duplicating it. The name is this crate; forge’s in-process MCP server is unrelated and is named as the forge MCP server.
forge-test-harnessThe wire-conformance harness. Replay tests plus opt-in live capture. Dev tooling, not in the runtime path.

The client - a Svelte page inside a Rust shell - lives in client/, its own workspace root, out of process entirely: it draws off the socket and reaches the core only through it.

Where new code goes

Work top-down; the first match wins.

  1. Audio, speech recognition, or turning either into text (model fetch, transcription, transcript normalization) goes in forge-dictate. It leans on the capture leaf for the sample rate and on nothing else of the workspace, and its own types stay there even once another crate reads them. The microphone itself - opening a device, negotiating its config, downmixing and resampling, handing out frames - goes in forge-capture, a leaf a client’s own shell can depend on without carrying dictation’s models. Text turned into speech (the engine, its model files, the sentence split, the utterance bound) goes in forge-tts, a leaf beside forge-dictate: the two directions have disjoint model families and dependencies, and a leaf holding both would be one crate with two unrelated engines.
  2. System One decision-model I/O (the [systemone] config shape, the wire question and answer types with their validation, the HTTP call) goes in forge-system-one. A leaf: it may not depend on any forge-* crate, and its own types stay there even once another crate reads them.
  3. A terminal’s PTY, screen model, command marks or repaint (the read loop, the OSC 133 scanner and the shell scripts, the text extractor, the state classifier) goes in forge-terminal. A leaf: it depends on no forge-* crate and knows nothing about a seat, a socket or a session.
  4. A type that crosses a crate boundary (an envelope, a snapshot struct, a hook payload, anything sent over a channel or touched by more than one crate) goes in forge-primitives. Data shapes only.
  5. Provider credential resolution, the usage probe, payload-to-snapshot mapping, billing shape or repair policy goes in forge-gateway, as one backend per provider token. Account selection, account health, probe scheduling or backoff goes there as well: the gateway owns the account pool, and the workspace drives it.
  6. I/O for an external integration, inbound or outbound (its stream client, REST lookups, subscription matching, a provider’s client and crypto) goes in forge-connectors, one module per integration. The connector holds no workspace state; Gotify reaches the workspace through the GotifyHost port that forge-workspace implements, Slack holds only its Web API client, and the vault module carries no session or keys.
  7. Anything that speaks stream-json to the subprocess (a decoder, a new control-request subtype, transport, the MCP host, the options builder) goes in forge-sdk, and ships with a wire-conformance scenario.
  8. Live state about the user’s environment (git watching, cwd resolution, environment probes, OAuth, plugins, settings I/O, plugin catalog scans) goes in forge-agent.
  9. The browser the sessions drive (a host process, the driver, a tab, a frame, an org profile, the idle clock) goes in forge-browser, which the server owns as a fleet and the workspace reaches through the BrowserHost port. A frame’s wire shape is a forge-primitives type; a viewer’s drawing is the client’s.
  10. Orchestration across projects, sessions, accounts, forge.toml or the command bus goes in forge-workspace. A read a VIEW needs is a verb on the view surface below, not a bare method.
  11. A session record as a view sees it, or a decision any view would make over one (the render-ready record, the reducer that derives it, the transcript fold that says where a conversation’s turns sit, the peer envelope parsing) goes in forge-server. The test is “does this render?” - if it does, it is the view’s.
  12. A widget, screen, layout or per-view presentation state goes in the client: draw it in client/src/, and take anything that needs the OS, a process or the socket to the shell behind the typed invoke surface. A further Rust view is a crate of its own, built against the socket in forge-server rather than against the core, and a read of the core goes through the view surface in forge-server, never through forge-workspace.
  13. A wire-conformance scenario goes in forge-test-harness.
  14. Supervision of a long-lived background task (its restart policy, its stop, the snapshot of what is running) goes in forge-supervise. A leaf: it depends on no forge-* crate, and a member’s own loop stays in the crate that owns it.

The view surface is built, reads and writes. A view reads the core through named verbs by subject - roster, session, agents, accounts, plugins, extensions, reviews, workers, connectors, dictate, biometric_enrolments, vault_state, dictate_models, voices, cli_version, conversation, slash_commands, forge_commands, subagents, subagent_cards, file_index, resumable_sessions, walk_file_index, respect_gitignore, header, mcp_servers, processes, work, board, background_tasks, monitors, pending_asks, fatal_error, service_status and usage - receives changes through subscribe_client(), and acts through dispatch(), which is a verb rather than an accessor so a view is handed the commands it needs and not the whole core. All of them exist in forge-server, and the client reads its project roster and agent rows, the task board’s fleet and per-project rows through board, the account pool, the worker registry, connector subscriptions and dictation state, the claude version through cli_version, its composer’s data through slash_commands, forge_commands, subagents, file_index, respect_gitignore and resumable_sessions, and the conversation, header, inspector and the asks it answers through conversation, header, mcp_servers, processes, monitors and pending_asks, and dispatches its composer’s send, its prompt answers and its take’s controls. walk_file_index is the one walk on the surface: a page that holds no seat has no loop behind it. subagents is the CLI’s catalogue of the agent types that exist, not a record of the ones that ran: an instance’s cards and the calls under them are folded from the conversation it appears in. Two refreshes ask the core for a new snapshot - refresh_context_usage, asked on a seat’s read, on a turn finishing on a seat a page shows, and on a compaction settling, and refresh_mcp_snapshot, asked on the seat’s read when it reports no snapshot. A read a further view would want goes on that surface; a read only one view makes stays a plain method.

Splits across several crates are normal; a git-diff feature naturally touches agent, workspace and server. The rule of thumb is that logic, I/O and subprocess work belong in the agent layer, cross-crate shapes in primitives, multi-session state in workspace, a session record as a view sees it in forge-server, and anything a person sees in the client. When in doubt between a shallow crate and a deeper one, push it down.

Five patterns get caught in review repeatedly:

  • Work in a view that belongs behind the port. A page draws; anything needing the OS, a process or the socket belongs on the shell’s side, behind the typed invoke surface. In Rust, spawning a subprocess belongs in forge-agent, exposed as a workspace method.
  • Adding a SessionUpdate variant for data that never leaves one view. If both producer and consumer are the same view, use a separate channel.
  • Defining the same shape in two crates. Lift it to primitives, or import the re-export.
  • Provider dispatch outside forge-gateway. A match on Provider in workspace or a view is the thing forge-gateway exists to delete.
  • Reaching around the command bus for a user action. User-initiated actions go through dispatch; query-style refreshes are direct methods.

The server stack and the client stack

Two stacks, and one test decides which side a thing is on: does removing it change what the data IS, or only how it is DRAWN? Only drawn means it belongs to the client.

  • The server stack is forge-server and everything under it: what a client reads of the core, the records as a view sees them, the folds, the working tree as state, and the socket that carries it. It is what a client cannot work out for itself, and it is the same for every client.
  • The client stack is whatever draws: the Svelte page in client/ today, whatever lands beside it after. Glyphs, colours, weights, spacing, an order chosen for display, the label a row is spelled with, the stripping of a command’s escape sequences - any choice about appearance rather than a fact about the session.

Three things hold at the line:

  • The client relies on the server completely, and forge.toml stays the single source of truth. So the server MAY hold a catalogue, a set, an index or a setting that a client reads - that is the server doing its job. What it may NOT hold is the decision about how any of it appears.
  • The server owns no git-level presentation. It carries the working tree as state - the branch, what changed, and the changed files’ raw hunks - and nothing that renders any of it: no colouring, no tree, no folding, no highlighted excerpt. A hunk crosses as lines with their kinds and line numbers, which is what changed; how any of it appears is the client’s.
  • A presentation the client needs moves into the client; it is never dropped. A thing both need keeps its home on the server with only the presentation half leaving, so the server stays the full statement of what the data is.

The view contract

One entry point in each direction, and the workspace’s stream fans out to however many views subscribe.

the client  --  Workspace::dispatch(Command)          ->  forge-workspace
the client  <-  SessionUpdate via subscribe_mirror()  --  forge-workspace
                / subscribe_answerer()

subscribe_mirror() and subscribe_answerer() hand every caller a stream of its own, so a second view attaches beside the one that exists rather than being refused the one receiver. A stream carries what the workspace emits after that call and nothing before it, so a view that attaches late reads what it missed from the subject’s snapshot; a view that drops its subscription leaves the fan-out. The old Workspace::subscribe() - the first caller took a pre-attach backlog - had no production caller and is gone. The socket’s own fold takes the mirror at boot, before any session spawns, and each client takes subscribe_client() through the surface with the role it declared.

A subscriber declares whether it can answer the workspace’s prompts. A view that renders a dock does, so a permission or question request delivered to it keeps its turn alive; a consumer that only reads takes an observing subscription instead, and a request that reaches no answering subscriber is resolved Cancelled rather than parked on a reply nobody will send.

That is the whole contract. There are no callback hooks, and no state shared between a view and the core. Query-style refreshes such as context usage and the MCP snapshot are plain Workspace methods rather than command variants, because they are reads rather than user actions.

Both enums address a session by its slot. A slot is the (org, project, label) triple forge-primitives’ SessionSlot carries: it names the seat, and the claude session id names the occupant. /new and /resume swap the occupant and leave the slot alone, so a Command carries a slot and a SessionUpdate routes on one; nothing infers a bucket from a wire session id. A session id survives only where the claude CLI or the child’s own address needs it: the --session-id / --resume arguments, the gateway binding’s URL segment, the stream-json session_id field, and the transcript filenames with the caches that mirror them. SessionUpdate::Connected and SessionReplaced also carry one, since they announce a new occupant: the id is their payload, the slot is still their address.

DomainSession, on the workspace side, keeps only workspace-internal routing metadata, plus the pending-interaction and turn-state bookkeeping the session actors need; the operational state a view renders lives on the session record forge-server derives for it.

One thing about this boundary surprises people: forge-workspace is a thin facade, not strong isolation. The boundary is enforced by the dependency graph (the client is out of process entirely), not by visibility: workspace re-exports the forge-agent submodules the server reads, so the server sees the agent’s surface under the workspace’s name. Do not read an isolation guarantee into it.

The in-process MCP server

forge exposes one MCP server, named forge, to every spawned session. It is not a subprocess: it is hosted inside forge and reached over the CLI’s own MCP transport. Its tools are grouped by submodule and render to the model as mcp__forge__<group>__<tool>, with thirteen groups today: agents, browser, documents, review, cron, tasks, gotify, slack, settings, systemone, terminal, voice and vault.

browser is the exception to the render pattern, crossing unprefixed: its tools are upstream @playwright/mcp’s own - mcp__forge__browser_navigate, not mcp__forge__browser__navigate - which is what keeps a prompt written against a Playwright MCP server resolving here unchanged. Every other group renders in full, the document hand-off included: documents__hand_off crosses as mcp__forge__documents__hand_off.

browser, documents, review, cron, tasks, gotify, slack, settings and terminal are registered for every session; systemone joins them when [systemone] is configured and enabled, voice joins them when [tts] is on, and vault joins them when [vault] is configured. Every vault__* call is held for the reader’s approval, and the shared session asks for the master password in the dock’s masked field on the first call after a start. A worker’s families can be narrowed at spawn - agents__spawn’s mcp_families list, revised by agents__update and stored on the worker’s durable record - and each withheld family is named in that worker’s own prompt. The agents group is always on, and so are browser, documents, settings and terminal: the browser is the server’s own - a session drives a host the server runs, whether or not any client is watching - a hand-off reads the caller’s own tree or its project’s document_roots, so there is nothing a project could withhold of either, and the config is every session’s own environment, read through the same document the settings page draws and written only through a held, catalogue-validated edit the reader approves, and the terminal is the seat’s own PTY - a shell the seat already reaches through Bash, so there is nothing a project could withhold of it that Bash does not already grant. Two groups vary by session kind: inside agents, any session may list, tell, ask and read its own identity, while the four verbs that act on the caller’s own project - spawn, despawn, update and capacity - are lead-only; and inside tasks, tasks__approve is the lead’s own door (it attests a look the user already gave) and is not offered to a worker. One tool does too: browser_end_host is a lead’s own door, offered to every session and refused to a worker, because it ends a browser host every session on the profile shares. Reach is the same for both: a target is a slot, (org, project, label), so a worker addresses another project’s agent as directly as its own lead.

Single instance per config directory

One forge process owns one config directory and runs many sessions inside it. When the guard is in force, a second instance is refused at boot, naming the holder’s PID when it can read one from the lockfile.

The guard is best-effort rather than absolute. If the application-support directory will not resolve, the locks/ directory cannot be created, the lockfile will not open, or flock fails for any reason other than the contended one, forge logs a warning and boots without the guarantee.

The lock is a non-blocking exclusive flock on a dedicated file. The holder truncates and rewrites its PID into that same file through the locked descriptor, which is safe; what must never happen is the file being replaced, because flock binds to the inode rather than the path. That is why it lives under forge’s machine-local application-support directory at locks/<hash>.lock and not in the config directory: a sync tool that replaces the file by rename would swap the lock out from under a running process on another machine.

The UI surface pages

The client’s pages live under ui/client/ and are the visual reference for every surface the client can currently draw, with the drawing each surface is held against beside it; the mark-provenance sheets stay in docs/mockups/. They are scoped to current state only, and a surface’s page lands in the same change as the surface.

The client’s standard

The client under client/ is a Svelte app that connects to a running forge and draws it. It is gated like the Rust side rather than beside it: just check runs the client web steps (Prettier, ESLint on typescript-eslint’s type-checked configs, svelte-check, tsc --noEmit and then vitest) and the shell crate’s steps (its rustfmt check client-tauri-fmt, its clippy and tests client-tauri-clippy and client-tauri-test, and its android warnings check client-android-warnings: the crate compiled for aarch64-linux-android with warnings denied, which is what catches desktop-only code going dead on Android) as two of its four independent groups, so one command decides both stacks and its verdict line names the first failing step whichever side it is on. The four groups run concurrently, capped by FORGE_CHECK_JOBS (default 4), and just check-serial runs the same steps one at a time.

The shell under client/src-tauri/ is its own workspace root, so just check’s workspace-side steps never reach it; CI compiles it in the client-tauri job (its clippy and tests), and the cargo fmt --check job’s second invocation carries its rustfmt check. Both jobs run when the shell, crates/forge-capture, clippy.toml, the root Cargo.toml or the toolchain changes. Every build and test job in ci.yml is path-filtered, and the ruleset requires two checks: ci-required, that workflow’s aggregate, which always runs and fails if a job whose paths changed was skipped, and the docs workflow’s build site, which runs on every pull request. client-tauri-fmt, client-tauri-clippy, client-tauri-test and client-android-warnings are the steps of just check that do, and the shipping build is still outside both gates. The Unicode punctuation gate, which CI runs too, and the client’s Prettier step do. just client-tauri-check builds the shell in the shipping configuration and just client-tauri-bundle adds the bundles, and the first is the one to run before handing over a change there.

The rules a gate cannot see are the same shape as the ones above. The denied constructs are any, non-null assertion, @ts-ignore, innerHTML, eval and floating promises, and a waiver carries its reason the way an #[allow] does. The form is not free: a hook in this repo blocks an inline suppression comment in TypeScript, so a waiver there is a scoped entry in client/eslint.config.js - and fixing the code so it needs none comes first. client/tsconfig.json keeps noUncheckedIndexedAccess, noImplicitReturns, noUnusedLocals, noUnusedParameters and exactOptionalPropertyTypes, because noImplicitReturns being off is what once let a lifecycle fall out of a match and return undefined, which threw at render instead of failing the build.

An unknown value from the wire is narrowed once, where it enters, under client/src/wire/, so every union downstream stays exhaustive and a variant the server adds is a compile error rather than a render crash. Every state a page can be in - loading, empty, failed, unknown - has a rendering, and a page that draws a healthy state for an unknown one is a defect rather than a gap. Accessibility is a rule: semantic markup, every interactive element reachable by keyboard, no colour as the only carrier of a state, and axe over the rendered markup as a page test. The design skills are picked up when the work is something a person will look at, and before the code is written rather than at review time: frontend-design to originate a look that has no drawing behind it, and ui-ux-pro-max with its family for the quality checklist, the tokens and the component specs. The order of authority is the mockup, then this standard, then the skills’ generic guidance - a salvaged or approved look is not re-litigated by a database of styles - and that order holds whether or not they are installed, since they are user-level plugins rather than something this repo carries.

And the shipped app is a shell. Its only input is the server URL: it carries no fixture, no mock data and no dev-only default, opens on the last address a connection was kept on and shows the connect screen when none does, and never falls back to bundled data. The fixtures live under client/src/dev/ and client/src/dev/fixture.test.ts builds the app and fails if one reaches the bundle.

The socket

forge serves one WebSocket, at /socket on the address [server] bind:port names. It is served from the process that already owns the sessions, so a client costs a listener rather than a second scheduler, a second cron store or a second set of connectors.

It serves data and nothing else. There is no page, no markup and no colouring on it: a client is its own program, it draws everything itself, and what it gets from here is what the core knows. The changed files cross as raw hunks - lines with their kinds and their line numbers, bounded and flagged - never as anything drawn.

Reaching it

Loopback by default. forge is reached over loopback or a private network and is never exposed publicly, which is the whole reason the socket carries no authentication: the network is what the access control stands on, not a proxy in front of a public bind.

A browser origin must be one of the user’s own. A WebSocket handshake carries the page’s Origin, and the server refuses a present origin that is not the shell’s (tauri://localhost, http://tauri.localhost, https://tauri.localhost), one of the server’s own (http://<bind>:<port>, read from the config), a 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 - or one the operator listed in [server] allowed_origins. A web client served from another address has its page origin in that list. 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. A request with no Origin is a native client - the protocol client, a harness - and is allowed, because browsers always send one. A refusal happens before the greeting, answers 403, and logs socket_origin_refused with the origin and the peer.

The connection is kept alive by a ping. The server pings every 15 seconds and ends a connection whose ping has gone unanswered for 60: a peer that vanished without closing would otherwise read nothing for ever, and the viewer it attached as would stay counted on its tab. The pair is chosen rather than measured. Every WebSocket client answers a ping itself, so a client needs no code for this; one that stops answering is disconnected. What a reconnect must do is the client’s own rule, and a seat is not like every other subject: forge’s clients re-ask the non-seat subjects they hold and re-attach only the seat they have on screen, leaving the rest stale until their next click - and a client re-attaching the seat it is showing must re-send Showing too, because the server’s showing died with the old connection and nothing else would spend the seat’s marks.

A client probes too, with ping. The server’s ping proves the peer is reading; it does not prove the peer will ever hear a close. A half-open socket - a locked phone, a network that went away - never delivers one, so a client that only answers would sit open for ever and draw pre-drop data as though it were live. Every client sends ping after 15 seconds of quiet, and the expiry closes the socket so the ordinary reconnect runs. The detection bound differs by transport, because what each can see differs. The shell’s transport (desktop and phone) counts bytes under the socket, so a slow frame is progress; it measures the quiet at beats, and a beat that only just saw traffic restarts it from itself - the worst case from the last byte is about 40 seconds. A browser is handed whole messages only, so its quiet restarts on every frame and its worst case from the last one is 15 seconds plus its deadline: about 80 seconds. The answer is a byte, and the deadlines differ by what each side can see. The shell’s transport counts bytes under the socket and calls the link dead 10 seconds after a probe that drew none. A browser is handed whole messages only, so its deadline is the write bound above plus a margin (65 seconds): while the server spends up to 60 seconds writing one frame it cannot answer a probe, and a tighter deadline would close a live socket mid-frame and redial into the same record for ever. The frame is application-level rather than a WebSocket control frame, because a browser page cannot send a Ping through its WebSocket API - one mechanism serves the desktop, the phone and the web build alike, and the server answers it with pong and no workspace involvement at all.

One frame write is bounded too. The server ends a connection when a single write takes longer than 60 seconds: a peer that stopped reading holds that write open while answering no ping, and TCP can take minutes to report the socket gone. A frame that was partly written cannot be resumed, so the connection is torn down rather than the frame retried. The bound sits above the slowest frame measured - a 3.5 MB snapshot to a slow client, 31.6 s - and matches the ping’s dead bound.

A connection whose own loop stops is ended as well. The ping is a branch in the connection’s loop, so it cannot see that loop parked in an await: a subsystem that never answers leaves the socket open with no ping, no reap and no error, which is how one freeze went unnoticed for 26.7 minutes. A watchdog outside the connection’s future, in the same task, samples its progress and ends the connection after four write bounds without one, then runs the same teardown a clean close does. Progress is a completed write, stamped where every frame is flushed rather than once per loop turn, because one turn can issue several bounded writes. Four write bounds also clear the unbounded snapshot encode the loop performs between writes, so a legitimate long operation cannot trip it and a healthy connection is never reaped into a redial loop.

The greeting

The first message a client receives is the greeting, before it has asked for anything:

{
  "kind": "greeting",
  "version": 29,
  "forge_version": "1.1.25 · abc1234",
  "forge_version_short": "1.1.25+abc1234",
  "settings": {
    "mark": null,
    "theme": null,
    "font": null,
    "dictate": {
      "styling": "semi_formal",
      "structure": "prose",
      "context": "general"
    }
  }
}

version is the protocol the server speaks. It is fixed rather than negotiated, because the server changes far more slowly than a client’s visuals do. Nothing is refused over it: the client records the server’s number beside its own, and a difference is a fact the surface draws - the rail footer carries both halves’ build and socket numbers, and a mismatch raises the notice beside them. Connection is never refused over a version. One thing is gated on it: a client sends no ping to a server older than the protocol that added the pair, because that server’s answer would be taken for a subscribe refusal (see below). Nothing is negotiated in either direction: the server reads no version from a client, because nothing a client sends carries one.

Every skew sentence says what the wire can carry. This client’s build and the command that updates the stale half are always named. The server’s build is named where the greeting carried it: a server ahead carries it, and an older one may speak a protocol from before the field existed.

forge_version and forge_version_short are the build serving the socket, the full stamp and the short one; the header draws the short one as its server build. They ride here because the greeting is the only channel both halves are guaranteed to have, so a skew that could name only a number would name nothing a person can act on.

settings carries forge.toml’s [client] keys: the three names mark, theme and font. They are a client’s settings rather than this server’s, and the greeting’s copy is the pre-attach handshake. The settings frame beside it is what the page and the client’s axes read, so a reload can re-theme an open page without a reconnect. A null is not a failure: it means the built-in. The browser’s idle clocks are the server’s and live under [browser]; they are not carried here.

settings.dictate is [dictate]’s three prompt axes, and it is a DEFAULT rather than a state: a client that captures its own audio holds the values in force per seat, so this is what its panel draws as unset and what its reset row returns to. Each take carries the values it normalized by.

The effective config

A settings frame follows the greeting in the same open, and is pushed again to every connection after each applied reload:

{
  "kind": "settings",
  "document": {
    "version": "1.1.34+abc1234",
    "source": "/home/me/.claude/forge/forge.toml",
    "fingerprint": "0123456789abcdef",
    "totals": { "keys": 232, "areas": 12 },
    "areas": [
      {
        "id": "server",
        "name": "server",
        "note": "the socket a client reads through, and the browser the server hosts",
        "keys": [
          {
            "path": "server.port",
            "leaf": "port",
            "group": ["server"],
            "pattern": "server.port",
            "kind": "number",
            "value": 8790,
            "class": "restart",
            "edit": {
              "kind": "number",
              "required": true
            }
          }
        ]
      }
    ]
  }
}

path is the concrete key, with the instance where the table repeats (accounts.Subspace.token). leaf is the key’s own name as a row draws it, and group the instance names the row belongs to - an account, an org and a project, a Slack table and workspace, a model, a server table - or an empty list when the area’s keys do not repeat. Both are the server’s, because a name can contain dots (a model slug, an env var, a display name) and a reader cannot cut one from path. pattern is the catalogue pattern the row fills (accounts[].token); a save sends it back so the writer locates the TOML item without re-parsing a display path. kind is the value’s shape for drawing: string, number, bool, list, table or set. class is one of live (read on its next use), next_session (read when a session or a connection opens), restart (built once at boot) or not_read_yet (the loader accepts the key and nothing consumes it). The class catalogue in crates/forge-workspace/src/settings.rs is the one source for the frame and for the reload’s own summary.

fingerprint is a digest of the raw file text the document was built from. edit says how a client draws the key’s control: kind is string, number, bool, enum or list; allowed holds an enum’s accepted names and is absent when the key has none; required says the key may not be unset, so an emptied optional string clears it while a required one keeps its value; and group names the repeated entry the key belongs to (account, project, slack, model, org), absent for a table’s own keys.

Values cross as the file loaded them, unredacted. The socket is reached over loopback or the private network, and that boundary is the decision; the server never writes a settings frame to the tracing log. There is no settings read verb: a connection attaching while a reload is in flight reads the current load from the next push, and the attach frame always reflects the current one. The document is built once per load and shared by every connection.

The frame is server-wide rather than seat-scoped. It carries no seat, it is delivered to a connection that has subscribed to nothing, and it arrives again on every reconnect.

Editing the config

A client saves a batch of edits with settings_edit:

{
  "kind": "settings_edit",
  "fingerprint": "0123456789abcdef",
  "id": 7,
  "edits": [
    { "area": "server", "key_path": "server.port", "pattern": "server.port",
      "op": "set", "value": 8791 }
  ]
}

fingerprint is the one the page loaded from its settings document; a file whose raw text has moved since refuses the save, and it is read again after the temp file exists and before the rename, so a file that moves in that window is refused too. id is the page’s own number for the save; every answer echoes it, so a page clears its draft only for the save that made it. area is the page’s area id the edit was made under. op is set, add or remove.

  • set writes a key: value is its new value in the edit.kind’s shape. A null value clears the key - the key goes back to unset and its default applies - which is how an optional string is emptied.
  • add appends an entry: pattern is empty, key_path names the new entry, value is its fields, and area is one of accounts, projects, connectors or models.
  • remove with a pattern removes that key; with an empty pattern it removes the entry key_path names, with the same four area values. The guards are identity diffs, so a rename is a removal too: a project or account the save leaves absent is checked exactly as a removal is.

A save that writes is answered with settings_saved, and the reload push follows it with every client’s new document:

{ "kind": "settings_saved", "id": 7 }

A save that writes nothing is answered with settings_refused:

{
  "kind": "settings_refused",
  "reason": "invalid",
  "id": 7,
  "message": "forge.toml at ... failed to parse at line 12, column 14 in `port`: ...",
  "key_path": "server.port"
}

reason is stale (the file moved; the page offers a reload), invalid (the candidate failed the boot loader; the message is the loader’s own, including its line and key where it has one), refused (a guard declined it, naming what it found) or io (the file could not be read or written; nothing was applied). The writer preserves the file’s comments and order, replaces the file by rename in the destination’s own mode and through a symlink, and the guards live on the server so a hand-rolled client cannot force them.

The same writer backs the settings MCP tools. A settings__set call carries one or more edits - a section’s mutually required keys go in the same one - and a settings__unset call one clear. Either parks as settings_write_pending on that seat, carrying every key with the value it holds now and the value the write would leave, and the blocked tool call waits for respond_settings_write { key, id, approved }. The batch is validated against the loader before the prompt is minted, so a save the file would refuse is refused without asking. Approving applies the whole batch through the save path above and the watcher’s reload push follows; declining writes nothing. A settings_write_resolved names the ending, so a view that did not answer it can close the dock.

A vault call parks the same way. Every vault__* call parks as vault_ask_pending on the seat, carrying a draft that draws no secret: the tool, the item, the field or masked diff, an attachment’s name and size, and whether the shared session is locked. The blocked tool call waits for respond_vault_ask { key, id, answer }; the answer is approve, send_back with a note, or decline, plus the unlock material when the card asked for it: the master password and 2FA code, or the refresh token and user key a platform authenticator released. An approval unlocks the one shared session and runs the call; needs_unlock is stamped from the live session on every read, so the other parked cards re-base to ordinary approve cards. The unlock pushes a restamped vault_ask_pending for each card still parked, so a dock that already drew one merges the frame by id; a call that then re-locks the session (a failed refresh, or a decryption that means the keys rotated) pushes the restamped frame the other way. vault_ask_resolved names the ending. The answer is a local channel: it travels the same connection the card came over, and the password’s privacy is the socket’s - [server] bind is the boundary, so a vault answer is only meaningful over loopback or the private network the config names.

A device enrols on the same session, with material the core hands out. vault_enrolment_material answers through reply_to with the session’s current refresh token and the account’s user key, or null while it is locked; the client wraps them under its platform authenticator and registers the blob with biometric_enrol. When the session’s refresh token rotates - the release redemption or the session’s own refresh - it pushes the slot-less vault_rotated with the new token, and the client holding the enrolment rewrites its blob through the update-only biometric_rewrite (a row the person created, never a new one); a client that cannot falls back to the password. The frame is live-socket-only: a device that was disconnected when the token rotated keeps the spent token, and its next gesture start falls back to the password once - enrolling that device again is the settings card’s enrol, which needs the vault open.

Subjects

Everything a client reads is addressed by a subject, and there are six: home, board, session <org>/<project>/<label>, usage, dictate_models and extensions.

A seat nobody has started is an answer rather than a silence. Subscribing to one that is not there comes back as an error saying so, so a client never draws an empty snapshot as a broken page.

How a message is tagged

Every message is tagged on kind, which sits beside the message’s own fields rather than replacing them. The bold name in the two sections below is that tag, so the subscribe below is {"kind": "subscribe", ...}.

The two payloads are tagged differently, and this is where a client goes wrong. command carries one of the core’s own Command values and update carries a SessionUpdate, and those two enums are tagged on the variant’s own name rather than on kind:

{
  "kind": "command",
  "command": {
    "cancel": { "key": { "org": "Acme", "project": "proj", "label": "lead" } }
  },
  "reply_to": null
}

A command’s variant is its name around its field bag - Command has 70 variants, 63 of them struct variants; the seven unit variants, dictate_catalogue_check, dictate_bench_stop, voice_bench_start, voice_bench_stop, plugin_update_all, plugin_check_updates and vault_lock, cross as the name alone. An update is the same shape one level in, {"kind": "update", "update": {"chat_appended": {"key": ..., "msg": ...}}}, and 79 of SessionUpdate’s 82 variants are struct variants too. The other three are why the update payload is not one shape: they are unit variants and cross as the name alone - "catalog_loaded", "cli_version_changed" and "dictate_availability".

What a client sends

ping - a liveness probe with no fields. A client sends it after 15 seconds of quiet, and a page sends one on a return from the background so a dead socket is caught by the deadline rather than up to a quiet spell later. A probe already out is neither asked again nor re-armed: a return while one is unanswered leaves its deadline standing, or a person returning every few seconds would keep a dead link open for ever. Any inbound traffic answers it - a byte to the shell’s transport, a whole frame to a page - and the pong exists so a quiet peer is always asked something. The answer’s value is the transport’s: it closes the socket on its own deadline (10 seconds on the shell’s transport, 65 in a browser; worst-case detection is about 40 seconds and about 80 respectively) and lets the ordinary reconnect run. The server clears its own keepalive on it like any other frame and answers with pong. A server older than the protocol that added the pair is never asked: its answer is a client_message error, which a client books as a subscribe refusal, and the greeting already said which server it is.

subscribe {what, answering, review} - answered with a snapshot of the whole subject. The subscription this opens is the one updates arrive on. The browser is not a capability a client declares: it lives on the server, and a client attaches to it as a viewer (browser_view below).

answering declares whether this client can answer a prompt, and it is off unless you say otherwise. The core parks a turn on the reply of whoever registered as answering, so a client that says so must be able to show what it is handed: one counted as able to answer and unable to draw would leave the turn waiting on a reply that never comes. A read-only dashboard says nothing and still READS the prompts - every update reaches every subscriber whatever its role, and no turn hangs on it, because the core asks only the answerers whether a prompt can be answered. A client with a dock to answer from sets this true, usually in its first subscribe, and the connection registers with the core accordingly before it forwards anything.

The declaration sticks for the connection, and it only goes one way. A later subscribe that leaves answering false does not take it back: the connection keeps the answering stream it already has, because the role is what the core counts when it decides whether a prompt can be answered, and a client that has drawn a dock once is not made an observer again by a second message. So say it on the first subscribe and expect it to stand.

review asks for the review page’s own read, and it is off unless you say otherwise. It is the read that checks the branch diff’s base (origin/<default>): it answers the last completed check the store holds inside its four-minute window, or base: unverified pinned to the local tip when it holds none, and kicks a throttled background refresh whose measurement answers the next read - so the diff the record carries and its base describe one check, drawn without waiting on the network. A seat with no session is the one exception: no watch runs for it to deliver a kicked check, so its review read runs the refresh and waits for it. A local base runs no fetch at all - the read probes whether it can lag behind anything and answers local when it cannot. When a fetch cannot run, diff.base_error carries git’s own words for why; a failed check is answered for the store’s window rather than retried on the next open. Every other read of a seat that has a session serves the stores and never checks the base; a record answered without it carries base: unverified beside a branch layer and no base_error. (A seat with no session is the exception the pr row below already states: its record looks the PR up on the read, because there is no loop to defer that to.)

The flag follows the page holding the seat rather than one ask, with one deliberate exception: a read a page asks for on a notice - a review write, another client’s activity - is an ordinary record read, so ten comments in a minute are ten records rather than ten base checks. The page’s own read follows it, and that is what re-states the base. The page says false again when it lets the seat go. That last one is the client’s own held state rather than a frame - nothing on this wire says “forget the scope” - so a reconnect after it re-asks an ordinary record.

browser_view {seat, tab, reply_to} and browser_view_page {seat, target, reply_to} - attach this connection to one of a seat’s browser tabs as a viewer. seat names the seat whose browser to view; tab is a tab’s number from the browser_tabs list, and omitting it takes the asking seat’s current tab, else the profile’s first. browser_view_page names the page instead - the CDP target id a hand-off carries - and is what a hand-off’s Open and its Retry send, because a tab number names a different tab after a host replacement. Both answer identically; a page that is gone is refused with the target and the tabs open now. The profile is not on the wire: a seat’s profile is forge.toml’s, resolved from the project’s browser_profile, else its org’s, else default. The answer is a browser_view naming the profile’s wire id, the profile key and the tab, a browser_nav_state for that tab, and frames as they paint - the newest kept frame first when one exists, so an attach on a still page draws at once. A client that waits for the answer sends reply_to: the attach’s own reply carries {"ok": true, "profile": ..., "tab": ...} with what it actually attached - a request that left the tab out is resolved to the seat’s current one - and {"ok": false, "why": ...} when the server refused it, so a surface that could not open draws the reason instead of a dark page. Attaching again moves what this connection is viewing: a re-attach to the tab it is already on keeps the viewer and the controller role it holds there, while another tab or another profile lets the old tab go - and the role with it - before the new one is attached. Every attach that lands is recorded (browser_attach with the profile and tab it took, the profile and tab it came from, and same_surface when the two are the same), and a refused attach drops whatever the connection was viewing and is recorded too (browser_attach_refused with the tab asked for, what was left and why). A connection that goes is detached with it. A hand-off’s tab is the tool call’s own: browser_hand_off names it by the page id its listing printed, an id no tab carries is refused before any ask is parked, and the ask crosses as the seat’s parked ask carrying both the tab that id resolved to and the page id itself. The surface’s Open is browser_view_page - the page id rather than the tab number, because a parked ask outlives the host that minted the number and a successor host’s first tab takes number 1 again. A page that is gone is refused with the tabs open now; a number-addressed browser_view is still what the strip’s own rows send.

browser_detach - stop viewing the attached tab. Lowering the takeover sends this: an attached viewer counts as a use, so a lowered surface gives its viewer up rather than keeping the profile warm. A connection that switches tabs does not detach first: the attach itself lets the old tab go, so a switch is one message rather than two.

browser_resize {width, height, scale} - lay the viewed tab’s page out to one viewer’s surface: device pixels and the ratio they draw at. A held role owns the layout: while a controller holds the tab only its size applies, so a watcher’s window never moves the page under the person driving it. With nobody holding the role the layout belongs to the tab’s longest-watching viewer, never to whoever asks - which is what lets a client with no shell lay out its view without taking control, while a second watcher cannot move the page under the first. A size the page already has asks nothing, so a re-attach or a re-measure does not move it. The host adopts the size on its next pump and the next frame is a key frame at it.

browser_input {seq, event} - one input event from the controlling viewer. The wire carries the person’s gesture, not the browser’s protocol: a press, a wheel, a key or inserted text, with the modifier keys held; the server turns a press and a wheel into CEF’s own mouse calls and a key or inserted text into CDP’s Input.*. seq is the viewer’s own ordering - the server drops an event at or below the last one the role carried, so a superseded gesture never lands late. Input from a viewer without the controller role is refused with an error naming browser_input, and a discrete gesture writes browser_input_refused. A pointer move is a stream: it is refused the same way but writes no record, because the click that follows carries its refusal. A gesture from a connection with no attach is answered the same way - a discrete one is refused and writes browser_input_unattached - while a move there is dropped with no answer and no record. A refused gesture is recorded with its kind and, for typed text, only its character count, never the characters: this browser signs people in.

browser_control {take} - take the controller role, or give it back. There is no default controller and no override machinery: a surface starts controller-less, any attached client takes the role with an explicit control, and exactly one holds it at a time. A take moves the role to the caller; the previous holder becomes a watcher and its input is refused from that moment. A take that does not land is answered, with an error naming browser_control, and where it failed is which record it leaves: a connection with no attach writes browser_control_unattached (take, has_browser, reason), while a host that refused the role - a tab that moved, a session that ended under it - writes browser_control with landed=false. A take that lands writes browser_control with landed=true, and a release writes browser_role_released once, when it actually held the role; a release that held nothing is not an event and writes nothing. Every viewer of the tab is sent the new state (browser_view), so the same truth is drawn everywhere.

browser_nav {back} - move through the page’s history, the takeover bar’s arrows. Only the controller’s move lands: a watcher’s is refused with an error naming browser_nav, the same rule input follows. The answer is a browser_nav_state for the tab, and the same frame is pushed whenever the page navigates on its own.

browser_file_answer {id, files} - the files a person chose for a page’s file chooser, each {name, data} with the bytes base64. The bytes cross the wire: the host is a windowless process on the server, so the server writes the chosen files beside the profile and hands them to the driver’s captured chooser (or CEF’s own dialog when no driver is attached). Only the controller may answer, and only the id the page’s outstanding ask names; anything else is refused before a byte is written. An empty list is a dismiss; past 16 files or 8 MiB the whole answer is a dismiss. The size is a chosen bound, not the wire’s: 8 MiB of bytes encodes to about 10.7 MiB of base64, comfortably inside the 16 MiB inbound bound, so the cap is there to keep one answer from spending the socket.

client_perf {records} - one page’s own perf records, for a client with no shell to write them. The records cross as the page serialised them and are written to forge-perf-web.log beside the server’s own forge.log, with one rolled copy; nothing answers the batch. A page with a shell writes the same records through its own invoke instead, so this is the web build’s path only.

browser_tabs {seat, reply_to} - the strip’s rows for one seat: the tabs of the profile its org and project resolve to. The answer rides the reply, so a client that draws the rows sends reply_to: {"ok": true, "tabs": {...}}, where the list is {profile, id, up, connected, closing_at_ms, rows}. profile is the key, <org>/<name>; id is the wire id the binary frames carry; up is whether the profile’s host is running; connected is whether a driver is attached to it; closing_at_ms is the idle clock’s deadline as Unix epoch milliseconds, or null when no host is up; and each row is {number, title, url, owner, opener, state, dialog}. number is the host’s own tab number, which frames and input already speak; owner is the session whose calls drive it, as {org, project, label}; opener is the tab whose page opened this one, or null for a tab a session owns outright; state is in_use (a call is driving it), idle, or held (a parked hand-off is waiting on the person in it); and dialog is the page dialog holding the tab, as {kind, message}, or null when none is open. The ask is the subscription: the connection follows the same row list from then on, and every change is pushed as a browser_tabs frame. A refusal answers {"ok": false, "why": ...}; with no reply_to, a refusal is an error frame and a success is silent.

terminal_view {seat, id?, reply_to} - attach this connection to a seat’s terminal as a viewer. The answer is a terminal_view frame carrying the screen as a VT stream and the sequence the byte stream continues from, then output frames as the shell writes them. id omitted opens the seat’s terminal, creating it on first use - the person’s door on a seat no agent has touched; a named id that is not the seat’s is refused with {"ok": false, "why": ...}.

terminal_detach - stop viewing the attached terminal. Lowering the surface sends this, so the idle clock and the viewer count see the truth.

terminal_resize {seat, id, cols, rows} - the attached surface’s size. The PTY and the server’s screen model resize together.

terminal_pause {paused} - client-driven flow control, so a phone that backgrounded stops being written to rather than being dropped. A resume is answered with a fresh terminal_view: the frames a pause missed are not replayed.

terminal_control {seat, id, take} - take the person’s control of the attached terminal (take: true) or give it back (false). One controller at a time, like the browser’s role: a take always lands and the previous holder is told it lost the role, and a release by anyone else is not theirs to make. Every viewer hears the result as a terminal_control frame. While a client holds it the terminal tools are refused, exactly as during a hand-off; lowering the surface or dropping the connection releases it.

terminal_close {seat, id} - the person’s close door: the terminal’s process group is killed and the seat’s next open starts a fresh shell.

There is no profile verb on the socket. A profile is forge.toml‘s - its org’s browser_profile, or the project’s override - so no client names, closes or deletes one, and a tab is the calls’ rather than the strip’s: it opens with a session’s first page call - or the host’s own boot tab, which that call adopts - and closes with its session or its host.

unsubscribe {what} - nothing comes back, because the client asked to stop hearing. Note that a second plain subscribe to one subject adds a second entry rather than replacing the first, so one unsubscribe drops one entry: a client that subscribed twice and unsubscribed once still hears that subject, and needs a second unsubscribe to stop. It is counted rather than flagged for a reason: two subscriptions to one seat are one seat still subscribed, and a single unsubscribe must not stop the frames the other subscription still wants.

A subscription carries the frames; showing below is what spends the marks. A client may keep a seat subscribed for a page it is not drawing, and that alone does not count as a visit as far as the rail is concerned.

showing {seat} - which seat this connection has on screen; null when it shows none. The last one wins: repeating the same seat is a no-op. Once the seat is also watched, it is what spends the needs-you diamond and the failure mark, and one of the two things that spend the death trace - the other is a prompt the reader themself sent, while a cron’s, a peer’s or forge’s own does not count as looking. Subscribing a seat does not show it. Nothing answers it, but a home snapshot may follow unsolicited when it spent a mark and this connection holds the home.

more {conversation, before, turns} - a page of a session’s conversation, as whole turns. Each turn carries key and messages, which is the same shape the session snapshot’s conversation carries; the grouping inside a turn is the client’s to decide.

A page carries cron_names where any of its rows is a fired prompt: the schedule each came from, keyed by the prompt’s own id, because the prose a reloaded row replays from holds the prompt and never the schedule that fired it. The map is absent when the page holds no fired row, and the same field rides the session snapshot’s conversation below.

seat_range {slot, epoch, from, to} - the frames a seat’s stream emitted between two numbers, for a client that read a gap in them. Every frame the workspace emits for a seat is numbered on that seat’s own counter, so a client that reads 5 and then 7 knows exactly one frame went missing; the seat’s header.seq says where the stream stands and the occupant’s session id beside it is the epoch those numbers belong to. The answer is seat_frames, and a range the seat’s own window no longer holds is answered from the session’s transcript instead. An error naming seat_range means read the seat again: a seat forge holds no session for, or one whose occupant has changed since those numbers were minted - a repair across two epochs would splice one conversation onto another.

A page that cannot be answered is REFUSED rather than answered empty. An empty page carries cursor: null, which a client reads as “nothing above” and stops asking on - so an answer the server could not give would make the seat’s history unreachable rather than merely late. An error naming more means ask again, not that the conversation is over.

A cursor below the held window is the exception, and it is answered either way. Past its oldest frame the window stops, and the page is read from the session’s own transcript on disk by the ROW the page above stopped at: the transcript’s own bytes, because the frames the CLI streams and never writes sit between the window’s rows and no arithmetic lines the two numberings up, so a walk from any cursor converges on the same descending chain of rows. Where that read cannot answer - no file, a row the file does not carry, one further from the file’s end than the read’s cap reaches - the answer IS the empty page, and what it ends is the history, not the seat. An error still means ask again.

A turn can carry a frame the CLI did not send. A backgrounded task’s ending reaches a transcript as a row of its own, and a row that opens no turn itself still lands in a later turn than the call it ends whenever something that does open one - a delivery, a peer message, a person’s next prompt - sits between the two, which happens to about a third of them. A turn holding such a call carries the ending as a frame too, which is what lets a client that folds one turn at a time read it. The forged frame has no uuid, because the row keeps the id the CLI minted for it and a second one would disagree; the row stays where it is, and a fold draws nothing for it, the ending being drawn on the call’s own row.

key is null on every turn of a transcript-derived conversation, and a client must not key by it. The name comes from a Result frame, and a transcript holds none: its reader maps user, assistant and system rows and nothing else. A client that keys its rows by key collapses the whole conversation into one. It is carried because a live session does name its turns, and what a client does with it is its own business - the cursor is a position and is the one handle that always names a turn.

The boundary between turns is the server’s where a page has one, and it is the only part of the fold that crosses. How a run of tool calls groups within a turn is a drawing decision; where one turn ends and the next begins is what the paging contract is built on, so a page above the window’s floor never hands over half a turn. A page below that floor can carry a stretch inside a turn - a running turn’s older rows are exactly that - and the server keeps what a fold needs to read it: a page never opens on a tool result without the call that produced it, and a running turn is the whole of what a late-attaching view can reach when the session never ends one.

The cursor is a position, not an index. Echo it back as before and do not take it apart: above the window’s floor it names the message the page’s first turn opens on, below it names the row the page stopped at by the byte that row starts at, and what either is made of is the server’s business. null means there is nothing above the page it came with, and that is where a walk backwards ends.

document_read {session, path, reviewable} - one document out of a seat’s own working tree or its project’s document_roots in forge.toml, for the client’s viewer. path resolves against that tree, and nothing outside the tree or those roots reads, however the path is spelled: a .. walk, an absolute path elsewhere and a symlink pointing out are each refused BY NAME. reviewable is what a hand-off asked for - the hand-off’s own fact rather than the reader’s, so it decides nothing about whether the viewer offers the document’s review - and a PDF asked reviewable is refused with the reason rather than quietly dropped. The read is bounded by the [documents] max_size_mib ceiling (10 unless forge.toml says otherwise), and a document over it is refused naming the size before any bytes are read. Every refusal is an error naming document_read, with the sentence as its why - missing, unreadable, outside the tree, over the ceiling, a PDF handed reviewable, or a markdown or text kind that is not UTF-8 - and that sentence is what a viewer draws as its own line. The answer is a document_read message or that error; those are its only two.

answer_document_hand_off {key, id, answer} - answer the parked document hand-off id for the seat key names. The answer is the reader’s decision, never a bare done: answer is {"approve": {"review_id": ..., "note": ...}} or {"feedback": {"review_id": ..., "note": ...}}, and "not_now" declines the ask before anything opens. review_id is the review the viewer’s finish filed under the document’s scope, null when nothing was commented; note is the reader’s own words, null when they wrote none. Approval is the one signal an agent may proceed on; feedback means the document is revised and handed off again. A call stays parked until one of the two is chosen, and a client that answers an ask no longer waiting is refused by name.

file_content {session, path, sha} - one file’s whole content at a revision, for a review page’s between-hunk expansion. The record’s diff carries bounded hunks, so the lines between them are what only this read can supply: sha is the scope’s own revision - a commit’s tree for a commit’s scope - and null reads the seat’s branch tip, the new side of the all-changes diff. The revision is resolved to a commit id before git sees it, so an option-shaped string is refused as a revision rather than read as a flag. The read is bounded by the same [documents] max_size_mib ceiling the document read is, and by the file’s SIZE first (git cat-file -s): a file inside the ceiling reads whole, and one over it answers the lines the ceiling reaches, flagged truncated, rather than refusing. There is no error arm: a read that could not run answers as a file_content message with the failed state and the sentence for its why, because a page draws that as the absence of a control rather than as a broken file.

Binary messages are frames, and a frame’s first byte says which kind. A client that captures sends one dictation frame per 20 ms of speech:

bytesfieldvalue
1kind0 = dictation; 1 = browser frame; 2 = browser audio; 4 = terminal output; 5 = terminal input
restsamplesi16 little-endian, mono, 16 kHz (kind 0)

A terminal output frame (kind 4) is the server’s: a little-endian u16 terminal id, a u32 sequence, then the shell’s own bytes. The sequence is per terminal and monotonic; a gap means the viewer fell behind and the server sends that attachment a fresh screen rather than replaying the bytes. A resume after a pause is answered the same way - the frames it missed are not replayed. A terminal input frame (kind 5) is a client’s: the same id and then the keystrokes. Both are refused from the wrong direction, as a browser frame is.

A dictation frame carries no seat. It addresses the take its own CONNECTION started: one connection streams one take at a time and its messages are ordered, so a frame can only arrive between its own take’s start and its stop. A frame this server cannot decode - a short header, an unknown kind, a payload past 16 KiB - or one that arrives with no take to hold it is dropped with a debug record and no answer, because a take’s audio has no reply channel and the take’s own outcome is what a reader sees either way.

1 is a browser frame, and only the server sends one. A viewer’s frames are relayed exactly as the host encoded them:

bytesfieldvalue
1kind1 = browser frame
2profilethe profile’s wire id, little-endian u16
2tabthe tab’s number, little-endian u16
4seqmonotonic per tab, little-endian u32
2widthdevice pixels, little-endian u16
2heightdevice pixels, little-endian u16
1format1 = WebP
1key1 = full quality (a surface’s first frame, a resize, an attach)
2page widththe page’s own viewport width, little-endian u16
2page heightthe page’s own viewport height, little-endian u16
restbytesthe encoded pixels, untouched by the server

The page’s viewport crosses beside the frame’s pixels, because that is the space a gesture is sent in: a picture letterboxed in a surface of another shape, or painted at another raster, still puts a press on the pixel it shows.

The server never re-encodes a frame. The host encodes once and the bytes reach every viewer as they left it. A viewer that falls behind is served the newest frame rather than a queue, and a stale frame is only ever superseded by a newer one for the same tab. A browser frame arriving from a client is refused: the host is the only encoder.

2 is the page’s own sound, and only the server sends one. A page the browser host is playing crosses the wire as interleaved PCM, so a person at a client hears it from their own device rather than the server’s:

bytesfieldvalue
1kind2 = browser audio
2profilethe profile’s wire id, little-endian u16
2tabthe tab’s number, little-endian u16
4seqmonotonic per tab, little-endian u32
4ratesamples a second, little-endian u32
1channelsinterleaved channels
restsamplesi16 little-endian, interleaved

One profile’s audio carries every tab’s sound, and a viewer hears only the tab it is showing. The viewed tab’s packets go to its controller alone: a watcher is looking at the page, not driving its sound. A packet that arrives after the player has fallen behind is dropped rather than queued, so the sound stays beside the picture.

3 is one compressed JSON frame, and only the server sends one. A connection that dialled its socket URL with deflate=1 declared that it can inflate one; from then on a ServerMessage body of 64 KiB or more crosses gzipped behind the tag, and a smaller frame is the text frame it has always been, byte for byte. The declaration is made at the upgrade, and a server that predates the kind ignores a query it does not read - so a client that never asked never meets a frame it cannot read.

bytesfieldvalue
1kind3 = one gzipped ServerMessage
restbodygzip of the frame’s JSON text

Order is the socket’s. Each transport inflates the frame in its own read path before the frames behind it are delivered, so a compressed snapshot and the updates that follow it keep the sequence the server wrote.

A binary frame from a client is audio or it is not a frame this server takes. The browser frames and the page’s sound are the server’s alone, so one arriving from a client is refused by its kind tag; nothing is waiting on it and no ask is failed by it. A refused frame is answered by nothing - a binary frame has no reply channel - so the debug record is what makes it legible.

dictate_stream {key, options, surface} - begin a take the CLIENT captures. The connection that sends it feeds the audio as the binary frames above, so nothing is opened on the server; the take registers as the message is dispatched, so the frame that follows on the same ordered connection always finds it. options is the axes that client’s panel was showing, and surface names the page whose own capture began the take (composer, review, files, doc), so the record can hand the landed words back to that page and no other. A server-side take records from forge’s machine, and its axes are the session’s stored overrides.

A take belongs to the connection that started it. Every dictate_* update about it - the start, the levels, the phases, the end - goes back to that connection alone: no other subscriber to the seat draws its meter, its phases or its words, and the seat’s record carries no take at all. A second dictate_stream for a seat whose take is live is refused with a sentence naming the reason, and only the refused connection hears it. A connection that goes away drops its take rather than submitting it - its reader is gone, so nothing more is transcribed and nothing lands anywhere. What does land sits in the box that began the take while the page that holds it is there. On the review, files and document pages a re-hand for a box the page no longer holds is dropped rather than placed in another box; the composer is the exception, and re-places its own carried landing after a remount. Pressing enter sends the words as an ordinary prompt every view then sees.

voice_mode {key, on, explicit} - a client turns voice on or off for the seat it is showing. It is that connection’s own switch, not the seat’s: two clients can watch one seat and only one of them be listening. The transport stamps the connection on it, so the listener set counts connections; the seat answers with a voice_mode_state carrying the count, to every subscriber of that seat. The first client in puts a note into the session, so the agent is told once that the person is listening and to send each update’s substance through mcp__forge__voice__say in a live-conversation register. The last client out tells the agent the person stopped and that it may stop speaking when the off is explicit, the reader’s own switch, and when the connection that held it goes away: a closed client or a dropped socket empties the seat’s listener set too, so the same note fires. A client that moves to another seat sends explicit: false and keeps its intent, so the seat pauses without toggling the agent and a return reconnects. The note rides a [Voice] envelope, so the transcript draws it as a delivery row rather than as the reader’s turn.

voice_preview {key, voice} - the settings page’s audition: one fixed line rendered in voice on that seat’s own voice stream, through the same voice_audio frames speech uses. It needs a client with voice on for the seat, and the core refuses it in its own words when none is on, so the page says to turn the speaker on rather than opening a second playback path.

voice_audio {key, seq, sample_rate, pcm} - one rendered clip of the seat’s speech, mono 16-bit samples at sample_rate. Clips arrive in seq order, numbered per seat in render order, and every connection subscribed to that seat receives them; a client plays them only while its own voice is on. voice_mode_state {key, listeners} carries how many clients have voice on for that seat, so a control shows the room rather than only itself.

command {command, reply_to?} - any of the core’s own commands, as the core’s own enum. reply_to is absent or null on most of them, and the two mean the same thing - a null is what the field is if a client writes it at all - because the command is fire-and-forget: its effect arrives through the subscription, which is why a client subscribes before it acts.

reply_to is required on exactly nine of them: a worker spawn, a despawn, the three review writes, and the four biometric commands - a release, an enrolment, a rotation’s update-only rewrite, and the vault’s material request. Their outcome rides the reply and no update carries it, so a client that omitted reply_to would not be declining a reply - it would be declining to learn whether the work happened.

Both mismatches are refused, with a sentence naming the field, and refused before the command runs: a required field left off, and a reply_to set on a command that has no reply to send. The second matters for the same reason the first does - a client that set it is waiting on a channel nothing will ever come down.

Each of the nine answers with its own type: a spawn with a worker-spawn reply, a despawn with a despawn result, the review writes with a bool and a review set, and the biometric four with the released blob, the enrolment’s true, the rewrite’s true/false, and the vault’s released material. One channel cannot carry nine different answers, so the reply type follows the command. A refusal arrives in that same reply, as the body of the answer rather than as an error - which is what the nine are for. A command that is not one of the nine refuses as an error like anything else the core declines, whether or not a reply was asked for.

read_call_output answers a call’s own output: the newest lines of the file the CLI streamed the task’s output to, or a named reason there is none. It answers while the task still runs as well as after it ends. Until the ending frame arrives the path is composed from the task id the launch frame carries - <base>/claude-<uid>/<slug>/<session-id>/tasks/<task-id>.output, where base is the same CLAUDE_CODE_TMPDIR the child runs with and the slug is a wildcard. The file itself is there from launch, so a read taken between the launch and the command’s first line comes back as an empty tail rather than as a path the read could not find; a path that resolves to nothing at all answers no-path. Once the task ends, the path its task_notification names is the one the read uses. Nothing is parsed out of prose on either side.

What a client receives

  • pong - the answer to a ping, with no fields. It is the client’s proof that the link carries frames in both directions, and a client draws nothing of it.

  • snapshot {subject, data} - a subject in full, in answer to a subscribe.

  • update {update, seq, span} - one SessionUpdate, for whichever subjects the client is subscribed to. The subject decides, and a home subscription has two arms rather than one: an update a home row draws something of, and an update belonging to no seat at all. Two home fields arrive only through the second: the service status and the fatal error. Read that arm as absent and a page keeps what it read at subscribe for the life of the connection. The account pool is a home field as well, and its announcement is also a fleet redraw, so it arrives through the first arm too; the frame carries the pool’s own state, so a usage-only move is applied instead of read. A move in the loading rows, the settled flag, the listener, a probe failure or the selection signal - at_cap and cooling on each pool row - is still a read, because a project row’s would_bind and its chip derive from those. A card left at 0 ready, probing looks like a slow probe rather than like a page that stopped listening, which is why the frame carries the state rather than staying a bare wake-up.

    The plugin records are keyless without being home fields. They cross to a home subscriber for the same reason - they name no seat - but nothing in the home’s snapshot carries them; their reader is the extensions subject, and a client declines the home read for them on purpose.

    seq and span are the frame’s place in its seat’s own sequence. Every frame the workspace emits for a seat is numbered on that seat’s counter, and the number is what lets a client see what it missed: a client that reads 5 and then 7 knows exactly one frame went missing and asks for it (seat_range), where a snapshot’s header.seq says where a fresh reader joins. seq is the number the frame’s OLDEST update was minted under and span how many of the seat’s numbers it stands for - 1 for a frame that is one update, and n for a run of token appends the batch folded into the one frame they draw as. seq is absent from a frame belonging to no seat, and span is 0 only on the turn the batch forges from a delivery

    • a frame with no number - and 1 everywhere else. Both are absent from a server older than the fields, which leaves a client with the frames alone. seq alone is absent from a seat’s frame the server answers out of band rather than emitting into the stream: the read_call_output answer carries its seat, seq: null and span: 1, because it stands nowhere in the sequence.
  • page {conversation, turns, cursor, cron_names} - in answer to more; cron_names is absent when the page holds no fired prompt.

  • seat_frames {slot, epoch, frames} - in answer to seat_range: the frames numbered in from..=to, oldest first, each {seq, update} with the update in the same shape a live update carries - so a repaired frame folds through the same pipeline a live one does. The range is answered from the seat’s own window while it holds the frames, and from the session’s transcript below that: the transcript’s own newest rows cross, numbered to END at the range’s end, because what a frame was numbered is minted in memory and written down nowhere. The client resumes exactly where it was reading rather than asking again for a stretch that can never arrive.

  • document_read {session, path, document_kind, bytes, reviewable, content_hash, threads, reviews} - in answer to document_read: the seat it was read for, the resolved absolute path the viewer addresses by, the kind the read detected (markdown, pdf, text, or {"other": "<extension>"} kept as itself), the bytes base64 - the ceiling keeps a document well inside the socket’s frame bound - whether the DOCUMENT is reviewable, which is its own fact: markdown and text are, a PDF and an unrecognised kind are not, so the viewer’s door does not depend on how the page was reached - the document’s own hash as DECIMAL DIGITS (the same convention an anchor’s content_hash crosses under), and the document’s scope read: its comment threads - each carrying a resolution naming where its anchor sits in THIS document now, re-found by its first captured line, or outdated when the revision left it nowhere; a read whose bytes are not text carries no landing at all, so resolution is null for every thread - and the reviews they were filed into. session is named back so a client holding two seats routes each answer to its own viewer; a refused read comes back as an error naming document_read, under the seat that asked - and so does one whose review store could not be read, because an answer with no comments would draw an uncommented document.

  • file_content {session, path, sha, content} - in answer to file_content: the three names of the ask back - so a page holding two seats, two scopes or several files routes each answer to its own ask, and the scope it names is the one that asked rather than the one on screen - and the read under content: {"state": "read", "lines": [...], "truncated": bool} for a read, {"state": "failed", "why": ...} for one that could not run. There is no error arm.

  • reply {reply_to, body} - in answer to a command that asked for one.

  • browser_view {id, profile, tab, controller, viewers, viewer} - one tab’s viewer state: the wire id the binary frames carry, the profile’s key, the tab’s number, the controller’s viewer id when one holds the role on that tab, how many clients are attached, and this connection’s own viewer id - the number controller names when the bar is this client’s to draw a release on. Sent on attach and whenever either changes, so every viewer draws the same truth about who is driving.

  • browser_nav_state {id, tab, can_back, can_forward} - where the viewed tab’s history sits: what the takeover bar’s arrows may do. Sent on attach, in answer to a move, and whenever the PAGE navigates on its own - a link click, a redirect or a same-document move - so the arrows do not wait for the next open.

  • browser_tabs {id, profile, up, connected, closing_at_ms, rows} - one profile’s tabs, pushed to a connection that asked with browser_tabs once per change: the same list the ask answered above, in the frame’s own shape. The strip draws its rows from this rather than polling.

  • browser_surface_failed {id, tab, reason} - the viewed tab’s browser stopped. Said out loud, because a frozen last frame reads as a slow page and the person has no way to tell the two apart.

  • terminal_view {seat, id, cols, rows, seq, screen, viewers, viewer, controller}

    • one terminal’s screen as a VT stream a client can draw, and where the byte stream continues from: seq is the last sequence the repaint reflects, so a chunk at or below it is one the screen already carries; viewer is this connection’s own viewer number and controller the viewer holding the person’s control, null when the agent does. Sent on attach and on a resume, never as a reply: the attach’s reply is its own {"ok": true, "id": ...}.
  • terminal_control {seat, id, controller} - who holds the terminal’s input, pushed to every viewer on a take or a release: the controller’s viewer id, or null for the agent. Every bar draws the same truth about whose keyboard it is.

  • terminal_changed {key, terminal} - a seat’s terminal opened, changed or closed, as an update frame and not a message of its own: the summary carries the id, the shell, the cwd, the size, whether the shell is alive, the state (with its source), the title and whether a hand-off holds it. terminal is null when the terminal is gone. It is pushed on every real state change - a command starts, a password is wanted, the shell exits - so the strip’s words never wait for a record re-read.

  • browser_file_dialog {id, tab, multiple} - a page asked for a file. The shell opens this machine’s picker and answers browser_file_answer under the same id; multiple is whether the page accepts more than one file and tab is the tab that asked. Only the controller of that tab is asked - a watcher, or a viewer on another tab, is sent nothing and does not cancel it. With no controller the chooser waits for the agent’s own browser_file_upload; CEF’s own dialog is the fallback when no driver is attached.

  • browser_download {tab, name, data, error, dropped, lost} - a page’s download finished on the server. The shell opens this machine’s save door and writes data (base64), or says error and writes nothing; a file past 32 MiB is refused in words rather than handed over. dropped is true when the offer was lost before this viewer read it (the backlog’s ceiling), so the shell titles the notice as a loss rather than a save that failed; it is false, and absent from an older server, for every download the host actually finished. A lost offer is the profile’s, not a tab’s: that notice carries no tab, and lost says how many were dropped, so the shell reads one loss or many from it. Only a controller is sent one - the controller of tab for a save, and of its own tab for a profile-level notice - and the server’s own copy is removed once it has crossed, whether or not a viewer took it.

  • error {what, why, seat, dispatch_id} - what failed and why, in the core’s own words. seat names the conversation a refusal belongs to where it belongs to one - a refused subscribe names the seat it refused as well as a refused more - and dispatch_id the dispatch the refused press made, so only that press hears its own refusal; both are null where they do not apply.

What a subject carries

The whole set, so a client author can see what is reachable without reading the code. A subject’s snapshot carries all of it, and its updates carry the pieces that change.

home is the fleet: every project, every account, and the app-level facts a row is drawn from.

FieldWhat it is
projectsOne row per project: project (name, org, path, sessions, has_model), work (branch, changed, gate) read at the project’s own path, would_bind, and chip - the account the row binds and its state. The board’s own detail - rows, schedules and connector sets - is the board subject’s (#2517).
fleetOne glance per project, for the home’s own fleet row: how many seats are live against the project’s cap, the queue (ready and unowned), the rows waiting on the user, the project’s total row count, and the project’s own misses by name.
agentsEvery seat’s row: slot, label, lifecycle, whether it has background work, what it is waiting on (the ask’s kind, how many wait behind it, and the ask’s own sequence), when it was last active, why it failed if it did, tree (the seat’s own directory, home-shortened, only when the worker runs somewhere other than its project’s), and work (branch, changed, gate) read at that seat’s OWN directory, which for a worker is its worktree and not its project.
unseenThe seats whose last turn finished while nobody was showing them. A mark is drawn from this, and nothing else can reconstruct it.
accountsLoading state per account, whether all of them settled, the gateway listener’s ready state and port, and each account’s cached usage snapshot.
connectorsThe connector liveness: Gotify’s connection, Slack’s workspaces and whether the stored subscription set failed to load. What a project is subscribed to is not here - those sets are per project and ride the board subject’s project row.
biometricThe device enrolments the settings page lists: each device, the purposes it enrolled, and each purpose’s enrol and last-release stamps. No wrapped blob is here - the bytes stay behind biometric_release, which answers the client that unwraps them.
vaultThe shared vault session’s lock state and idle bound, and null when the config declares no [vault]. The client draws the settings vault card from it.
dictateWhether dictation is on, the per-model fetch and load state, and where the models land.
voiceWhether [tts] enabled is set, and where the speaking model’s files land.
cli_versionThe installed and latest claude versions.
forge_version, forge_version_shortWhich forge build is serving the socket. A client draws it as the server build beside its own client build.
boot_idThe server run’s own identity, minted once per process. An ask’s sequence restarts with the run, so a client scopes the asks it has announced by this - a mark from a dead run can never match a live one.
service_statusThe statuspage’s last answer, null for healthy or unreachable.
fatal_errorThe last fatal error the core held, null when there has been none.

board is every project’s board: the rows, schedules and connector sets the board page and the session rail draw. It is a subject of its own because a home read happens on every update that covers the fleet, and these three fields were most of a ~708 KB home read for a page that draws none of them (#2517).

FieldWhat it is
projectsOne entry per project: project (its identity - org and name; the rest of a project’s view is the home’s), rows (the board: each one a task with its worked time, ages and marks, plus its rollup and its epic’s subject), crons, and connectors (this project’s own gotify and slack subscription sets).

board hears the variants that move it: tasks_changed, cron_schedules_changed and connector_subscriptions_changed, plus the seat events its rows’ stale mark is read from (spawning, connected, session_replaced, releasing, connection_failed, worker_status_changed). A row’s ages and its time-derived marks move with the clock and no frame announces that, so a page holding the subject re-reads on a slow beat of its own - 30 s - while it is mounted. The home hears tasks_changed too, because its fleet row’s counts are derived from the same tasks; it hears neither of the other two, because its snapshot no longer carries them.

session <org>/<project>/<label> is one seat: its record, its conversation, and what the composer is doing.

FieldWhat it is
slotThe seat itself.
headerThe occupant’s session id - the one a copy control hands out, and null when there is no occupant to name (nothing started, nothing connected yet, or one dropped after a sign-in or a failed connection) - the resolved model and the catalogue a picker draws from, the effort level, the permission mode, context usage, asked for on the reads that encode a subject when the seat has none, again when a turn finishes on a seat a page shows, and again when a compaction settles, a page or not (the socket issues the ask whether or not an agent is behind the seat, so where there is none it is refused and nothing reaches the CLI; the answer lands as a context_usage_snapshot update only where there is one) and whether a turn is in flight. seq is the number the seat’s next frame will carry, and the occupant’s session id beside it is the epoch that number belongs to: a client that attaches mid-stream reads where the stream stands, so the frames it hears from here continue the count it holds rather than looking like a gap.
conversationThe NEWEST turns, in order, with the compaction count and the cursor above them (cursor, the same handle a more answers with), and cron_names for any fired-prompt row it holds (the schedule each came from, keyed by the prompt’s own id, absent when there is none) - up to twenty of them, the same page a more answers with, so a client seeds its conversation from this answer and asks for what is above it rather than for the page again. An older server’s record omits cursor: the page still seeds, and the client asks for the newest page rather than reading the history above as the end - a more answer names the window’s floor, and the field is additive, so a client never refuses a snapshot that lacks it. (Fewer than twenty when the held window’s 64 MiB byte ceiling has dropped messages off its front, or when [server] first_frame_cap_kb holds the page below that many turns: the newest whole ones are kept, and the front of what it keeps is where the walk below the floor begins.) Each turn carries key and messages, the frames the turn ran as. The live update stream carries those too, and both paths fold a run of consecutive token appends into ONE frame carrying the summed estimated_tokens_delta - the stream by its flush, a page by its turn, except a turn whose first carried frame is a counter (the window’s front cut inside a running turn, so its leading run is served as itself) - and a page’s frame adds folded_from and folded_frames: the run’s first stamp and how many frames the one stands for, so a client landing it knows which counters the frame already covers. The two differ elsewhere: a forged frame carries the id its prompt or delivery was minted under, which is the id the read’s copy of the same row carries - the two pair on it - while a frame the CLI sent carries the CLI’s own. A page differs the other way as well - it carries an ending for a backgrounded task’s call as a frame of its own, which the stream never sends - so read these as the ones this row states rather than as the whole list. Every path to a client carries the same bound on a frame’s structured record: a tool_use_result over [server] tool_record_cap_kb is reduced to the keys a client draws (a mutation’s patch and outside-edit mark, a question’s answers and notes), and arrives as a {omitted, bytes} marker only when that reduction is still over the cap. The transcript keeps the record whole.
workThe working tree as state: branch, how much changed, and whether git runs here.
gitThe tree behind the row’s depth: default_branch (which branch the tree is measured against), worktree (the uncommitted files, each with its path, added and removed counts and its status - modified / added / deleted / renamed / copied / typechange / unmerged / untracked) and ahead (the branch’s commit_count, the chain itself - each commit a short sha, subject, its own stats and the instant it was committed in unix seconds - and the range’s own stats) all come from one scan, and base is what the last read that measured it found for the base ahead is read against - local, current or unverified from this server, behind as a value a client still draws, null until a read has measured one. A scan only kicks a refresh for a remote-tracking default and never waits for it, so base is what the last completed refresh measured - a watch pass’s kick, or a review page’s own read (the one that waits when the seat has no session) - and local arrives from a review read’s own probe of a base with no remote-tracking ref; base may be older than the scan beside it, because it is carried across scans that do not refresh. Beside it sits base_error - git’s own first line for a refresh that could not run, null when it ran or no measurement has landed - carried with the state so a page that hears the row names the reason rather than only that the base could not be checked. Every stats block carries the full totals and the busiest files up to the scan’s cap of seven, each classified with the same statuses the uncommitted layer speaks; the chain is capped at the scan’s walk. null layers are nothing to state - a clean tree, an unscanned one - and it moves with work_changed frames like the row above it. base is what makes a base-only move news: an upstream advance the branch does not contain moves origin/main while the branch’s own count and chain stay exactly as they were, so that field is the one that changes. A completed check is itself news: a refresh that landed after the row was taken is pushed on the seat’s next watch pass whenever its state or its base_error differs from what the row already states, because a page that drew the base unchecked is waiting for that check; a check that found exactly the same thing again is not news.
pr, closesThe open pull request this seat’s branch is on - its number and URL, and whether it is a draft - and the issues it closes, which is the PR #N -> closes #M line a view draws under its PR row. null and an empty list when there is none, or when the branch is not pushed. The record carries the PR the seat’s last scan knows: the lookup is one network call, two when it finds a PR (the second fetches its closing issues), and a record read never waits on it - a cache past its refetch window is served as it stands and the seat’s own watch looks it up on its next pass, pushing the corrected row as work_changed. So a first open after that window can draw the previous value, or nothing when the seat has no scan yet, for about two seconds when the lookup finds a PR and about one when it finds none. A seat with no session has no store to serve and no watch to correct it, so its record still looks the PR up on the read.
diffThe changed files with their raw hunks - data, never a rendering. Both layers the work row’s state describes (worktree against HEAD, branch_ahead against the merge base), each clean, populated or scan_failed; a populated file carries its path, the old path a rename came from, its status, binary / submodule / truncated flags, and the carried lines with their kind, text and line numbers. Bounded per file and per read - 400 lines and 32 KiB per file, 512 KiB of carried line text over 100 files - with every cap that bites flagged. Beside the two layers sits base - local, current or unverified from this server, behind as a value a client must still draw - the state of the last completed check of the remote-tracking base the diff is measured against. A successful refresh trusts the ref its explicit refspec wrote and answers current; a fetch that could not run reads no remote tip, so it answers unverified with git’s own words and no lag count. behind is {"behind": {"behind": N}} in JSON (serde nests a struct variant under its name); this server no longer measures one. An ordinary record read never refreshes the base, so it answers unverified beside a branch layer. A review-scoped read (a subscribe with review: true) answers at once on a seat with a session: the last completed measurement inside the store’s window, words and all, or unverified when it holds none, and kicks a throttled background refresh whose measurement answers the next read. A check outside that window is held and ignored, so a base drawn as current can be up to four minutes old, and the record carries its state rather than its age. A seat with no session has no watch to deliver that measurement, so its review read runs the refresh and waits for it; and a local base runs no fetch at all, the read probing whether it can lag behind anything and answering local when it cannot. The measurement then reaches the page as a work_changed frame, and the page’s own read against it answers the checked base. null when there is no branch layer. Beside it sits base_error - git’s own first line for a base fetch that could not run, null when the fetch ran or no branch layer is drawn; a measurement a completed refresh left carries its failure words beside its state, so a reused answer names them too - so a page names what failed rather than only that the base could not be checked. Read once per record and never pushed; a stored scan past its window is taken again before the record reads it, so its hunks are at most that window older than the row beside them, and the tree’s later movement arrives as work updates.
file_indexEvery file under the session’s scan cwd, walked with the user’s own gitignore preference and with the root’s own .git dropped whether or not that preference is on, and pushed as file_index_changed when a walk finds it moved. The walk follows the seat’s change watch: writes inside one poke become one walk, a still tree is walked at most once per five seconds, and a frame goes out only when a walk finds the index differing from what the seat’s readers already hold - the record’s snapshot, or the frame last announced.
mcpThe session’s MCP servers, their status and tools, and the failure when the read did not complete.
processesThe last walk of the session’s process tree, or null for a seat nothing has walked - a seat a client holds is walked once a second and its movement is pushed as a processes_changed update, a seat nobody holds is not walked at all, and a session ending clears it.
background_tasksThe seat’s live tasks: the CLI’s background registry plus the long foreground calls it announces on task_started and ends with a task_notification, a terminal task_updated, or the turn the call ran in, each entry saying whether the CLI backgrounded it (is_backgrounded), with the line the row leads with, the command its own call carried and the call that began it. The processes feed leads its rows with these, because a backgrounded bash is detached from claude’s tree and the OS walk cannot see it for itself.
monitorsThe watches the session has running.
terminalThe seat’s terminal as the strip draws it, null until one is opened: the id, shell, cwd, size, whether the shell is alive, the state (with its source), the title and whether a hand-off holds it. Pushed as terminal_changed on every real state change, so the strip’s words never wait for a record re-read.
pending_asksEvery prompt the seat is holding, oldest first within each kind that keeps arrival order, the held kinds leading (a Slack draft, a browser hand-off, a document hand-off, a terminal hand-off, a held vault call, a held configuration write); the vault registry keeps no arrival order, so its own cards have none to offer. Two questions of one batch or two calls that ran in parallel park several at once, so this is what a client that attached mid-batch reads.
pending_askThe front of pending_asks - the prompt a client that draws a single ask waits on, null when there is none. Kept beside the list so a client reading only this one still draws the oldest ask.
reviewsThe review threads and the submitted reviews, each read separately so an unreadable one is not reported as empty.
slash_commands, subagentsWhat the CLI last advertised: its commands and its agent-type catalogue, pushed as slash_commands_changed / subagents_changed when a turn’s init (or a plugin reload, for the commands) moves them.
stateThe seat’s scan cwd, queue - the prompts still waiting in the CLI’s queue, oldest first, each {uuid, source, text} - and voice_listeners, the clients with voice on for the seat - this one too while its switch is on. The queue is a fact about the seat, so it is read here as well as followed on the stream: prompt_queued adds a row and prompt_lifecycle settles it. voice_listeners is what a late attach seeds its control from before any voice_mode_state arrives.
composerWhat the composer is doing: the machine turn running on the seat (machine, handoff or compacting, null when it takes input), the push-to-talk key and mode forge.toml configures, and the last landed take nobody has sent (notice, with carried: true and the surface whose capture began it - null for a server-side take or a client older than the field - which a page reads back after a reload). No take: a take belongs to the connection that started it, so its meter and its phases ride that connection’s own updates and never the record. The asks it is answering ride pending_asks rather than being copied here.

usage is the token/cost pool, scanned on the ask.

FieldWhat it is
today, week, month, lifetimeOne window each, holding the same two groupings: by_model and by_project rows of tokens and notional cost, plus the total row. Each list is sorted by cost, descending.
pricing_availableWhether a price table was loaded when the report was built. false means every cost is a placeholder zero, which is why it crosses rather than being left to a client to infer.

Nothing updates this subject. It is a scan of the session-JSONL pool, not a feed: no update announces that a transcript’s tokens moved, so a subscription is answered once and then hears nothing. A client that wants the current numbers asks again by subscribing again.

dictate_models is the models page’s read: the pinned models, the catalogue check, every catalogue row, and what the feed proposes adopting. It is a subject of its own rather than a field of home because the rows are the whole catalogue and only the models page draws them.

FieldWhat it is
enabledWhether [dictate] enabled is set. Carried rather than inferred from an empty in_use, which a failed preflight empties while a switched-off section still carries the speaking row when [tts] is on.
models_dirWhere the dictation models land, or null when the platform has no usable cache directory and none was configured.
speakingThe speaking role’s own read: enabled, models_dir, speaking_model (the model’s own name), snapshot (the role’s own model row and its state), active (the variant that runs, with its variant, file, quant, size, installed and active), from (a bare name: a [tts] speaking_model pin, an installed pick, or the compiled pin), variants (every ONNX variant with whether it is installed and whether it runs), voices (the installed voice ids), voice (the configured id, or null for the engine’s default) and preview_error (the last preview’s render failure, or null).
in_useOne row per model the role runs: role, file, size, sha256 (the declared digest, or null for an installed model that publishes none), the preflight state for a dictation row (pending, downloading, verifying, fetched, loading, ready, failed) or pending / ready for the speaking row from its installed set, facts - what the pin declares about the checkpoint (quant, parameters, licence, runtime) - catalogue, the feed’s entry for this file joined by file name, with the pin’s byte length as the witness, when the feed carries one, and from / at, where the choice came from (a [dictate] or [tts] config key, an installed pick, or the compiled pin) and when a runtime pick chose it. The speaking row follows [tts] speaking_model, the installed pick or the compiled pin, and its catalogue is null because the Hub, not the feed, carries it.
checkThe last catalogue check, tagged on state: never, checking, fresh (at, release, skipped) or unreachable (error). The rows stand through an unreachable: a failed check costs the freshness line, not the feed.
updatesOne entry per in-service model the feed can read against: role, file, current (the in-use model’s own catalogue numbers, speed_x and fleurs_en_wer) and candidates, every comparable row ranked, each with the rule’s verdict on it and which one the rule picks.
rowsThe whole feed, one row per variant: display identity, parameters, licence, languages, streaming, download (the preferred quant and its size), speed (the m4-max Metal row), wer (FLEURS English where the feed carries it, else the entry’s own headline), kind (which role’s list the row belongs in), url (the entry’s own page, where the feed names one) and download_count (the Hub’s own count for a cleanup candidate, absent from the speech feed).
install, activateWhere the last model download and the last role swap got to, tagged on state (idle, downloading with its bytes, activating with its role, failed with the core’s own reason).
installedEvery model this machine downloaded from the feed: variant, file, url, size, facts and when it landed.
bench, resultsThe bench in flight - its target, its tier, its clip count and the agreement so far - and every finished run, each with its metrics, its tier, its target and the corpus it scored (clips, audio seconds, digest). Only runs sharing one tier and one corpus are comparable.
speaking_bench, speaking_resultsThe speaking loop’s own run - its clip count and progress, or its failure - and every finished run, each with its round-trip word error, first-audio latency, realtime factor, peak_rss_bytes (the process’s peak resident set above the run’s baseline), the model file and the corpus it scored.
read_aloudThe read-aloud set: the recordings kept here (id, duration, bytes, digest, when), whether one is being recorded right now, the passage they are read from, the terms a run scores them on, and the last write’s failure when there was one.

A candidate beats the model in use on both measured axes or it is not one: lower FLEURS English word error rate and higher m4-max Metal wall-clock realtime factor, both from the feed’s own rows. The entry must also carry English and must not be the variant already in use. An entry whose file name matches the pin at a different byte length has been rebuilt, and is never a comparison. Among the entries that clear all of it, the fastest wins; a non-commercial licence is a fact on the row rather than a filter, because what an adoption does now is download a model onto this machine. Nothing is adopted without a press: the page proposes, and the person presses - the core only ever does what a dictate_activate names.

The check’s landing is the update. dictate_catalogue_check starts a fetch, and dictate_models_changed carries the re-read view when it answers; a refresh due at boot lands the same way, so a page that attached mid-check hears the result without asking again. dictate_availability reaches this subject too: when the preflight finishes loading the models - which it announces with that update alone - a page reads again rather than drawing pending chips until the next check.

extensions is the Extensions page’s read: the plugin inventory, the PATH checks the scan cannot carry, and the last update run. A subject of its own rather than a field of home for dictate_models’ reason - only that page draws any of it.

FieldWhat it is
snapshotThe disk scan, or null when no plugins root resolves (CLAUDE_CONFIG_DIR or HOME), which is its own state rather than an empty machine. Carries installed (the registry entries: id, version, scope, enabled, install path), marketplace (the catalog each marketplace offers), marketplaces (the configured sources: name, source kind, repo, clone location), components (per plugin: its skills, agents, commands, hook trigger events, an mcp flag, its declared lsp servers, its available_version, the auto dependency mark, and a load_error with its reason), marketplace_health (per marketplace: plugin count, load error, install-location drift), and token_costs (the always-on cost per plugin version, empty until a producer fetches it).
lspOne row per declared LSP server of an INSTALLED plugin: the plugin, the server, the command its manifest declares, and whether that command resolves on the machine’s PATH. Computed here because it is a fact about the machine the socket is on.
update_runThe last update run or check, in flight or finished: the trigger, whether it has finished, and one row per plugin with its scope, its status (queued, updating, updated, already_current, failed, skipped, update_available), the versions on either side, and the failure’s own words.

The read and the frames are one shape. plugins_inventory_updated embeds the snapshot object and plugins_update_run_progress / _finished embed the run, so a page that took one off the stream and a page that read it are drawing the same bytes. The plugins_* family reaches this subject in full: an action’s outcome, a run’s rows moving, a failed refresh, and a rollback’s result. plugins_cli_action_failed and plugins_inventory_refresh_failed carry the core’s own words for a press that ran and failed, which is what a page draws when it has nothing else.

Two representations of one conversation cross, and they agree. A settled turn arrives by more as the messages it ran as; the turn in flight arrives as the same CLI frames, one update at a time - except that a run of consecutive token appends inside one flush crosses as ONE frame carrying what they grew by summed, where a page’s copy of the same turn carries the CLI’s own several. A client folds both with one rule of its own - how a run of tool calls groups, the labels it draws and the tail it shows - and the two meet with nothing to reconcile about their shape, because the frames are the same shape on either side. The one thing the server keeps is the turn boundary where a page has one - and, below the window’s floor, the call a result belongs to, so a page never opens on a result whose call it does not carry; where a turn BEGINS is a fact about the session, and how its work is drawn is not. Neither view is short of a token for the fold: both draw a turn’s estimate as the sum of the deltas, so the two agree on the number where they differ in frames. What can differ is the id, and only for the forged frame below.

Not every frame on the stream is the CLI’s, and for the forged kind the id is the tell. A prompt handed to claude on stdin is not echoed back, so forge forges the user turn itself and sends it as a chat_appended like any other frame: a delivery (a cron fire, a Gotify notification, a Slack message, a peer comm), forge’s own machinery (a worker kick, an auto-continue, a board chase, a board notice), and a reader’s own send, which without it no viewer but the sender would ever see. The folded frame is the other kind, and its id is the CLI’s: it is the last arrival’s, because the one frame stands for the run the flush carried.

A prompt frame carries no origin since v27. The field said whether the core’s own input handler or a view submitted the words, and it is gone with the dispatch-time row it described: the row is drawn when the CLI starts the prompt, so no frame needs it. A client older than v27 reads the field as absent; what it draws for a dispatch is that client’s own rule, unchanged by the missing field.

And it carries a source, the same label the queued row wears: you for a person’s send, and the delivery’s or the machinery’s own kind otherwise. A frame’s shape cannot say this - forge’s machinery forges prose as the reader’s own row on the frame a person’s send uses - so it is the one field that tells a reader’s words from forge’s when a client decides whether new words should bring a scrolled-up reader back to the foot.

A forged turn carries the prompt’s own uuid, the id the prompt was dispatched under. The CLI stamps the prompt’s id into the transcript it persists - on the user row of a prompt that started a turn, and inside the queued_command block’s source_uuid when a mid-turn prompt is delivered as an attachment - so the frame and the page’s later copy of the same message agree on it, and a client reconciles the two by id. It is also what lets a view hold the forged row while the prompt waits in the pile: the command_lifecycle frames carry the same id.

The server’s fold and the client’s are two readings of one conversation. The server folds a conversation into where its turns sit and the task endings it holds, which is what its pages are cut on and what a carried ending rides; the client’s chat makes its own calls over the same frames - its own unit fold, its own lifecycle carving, its own status aggregation. The drawing is the client’s, and the client’s answer is the one on screen.

What is not here

  • Anything drawn from the diff. The changed files and their raw hunks cross, bounded and flagged (the session table’s diff row); what does not is any rendering of them - glyphs, colours, folding, a tree, highlighting. The two heavier reads a review surface wants are here now: one commit’s own diff (commit_diff, asked for on demand rather than carried on the record) and one file’s whole content at a revision (file_content, above, for a between-hunk expansion). What is still not here is a diff against an arbitrary base.
  • The rest of the extensions surface. The inventory, the PATH checks, the last run and six actions cross (extensions, above: install, one update, update-all, a check, marketplace add and marketplace repair). What does not is the per-plugin details overlay and the verbs it holds - enable, disable, uninstall, roll back, marketplace remove and update - and the always-on token cost a plugin charges. Filed as its own piece of work (the details overlay and the rest of the plugin verbs).
  • The emoji set. Which shortcodes exist is the typeahead’s own business, so a client carries its own set rather than being handed one.
  • forge’s own command table. The commands forge handles itself, and what each one does, live on the server: forge_commands is a read on the view surface, and both views that exist take the table from there rather than carrying a copy. It is not a wire subject, so a client has to know the set it is offering - and the names it offers on top of that are the CLI’s, from slash_commands. A client that hardcodes its own copy should expect it to fall behind the core’s command enum, because the two are the same list and only one of them is generated from the code.
  • The CLI’s own surfaces. slash_commands carries the names the CLI advertises, /config among them. Some of those names open a dialog the CLI itself draws; the name crosses and the surface does not, so a client that offers one is offering a command whose UI it cannot show.
  • Any rendering. Glyphs, colours, weights, spacing, the order of a list and the label a row is spelled with are the client’s. The test is whether removing a thing changes what the data IS or only how it is DRAWN.
  • A shipped client. The client under client/ speaks this socket, and it is what this page is for. It is a client rather than an instrument, and it runs from just client-dev rather than from an installed bundle, so what is missing is a released one rather than a consumer. The instruments are three, and they are not equal: forge-protocol-client and forge-fold-cost, both under crates/forge-server/src/bin/ and neither installed nor shipped - the first by a normal build, the second only with --features testing, because it serves a fixture fleet - and the integration tests that open real clients against a server they start themselves. forge-fold-cost also measures: it prices one request in ps CPU time against an idle arm, on a server over a real transcript, and every arm prints the bytes and turns it moved so a reader can tell an arm that stopped seeing the conversation from one that worked.

Today

The forge binary is the server: it boots the core and binds the socket, so a running forge serves one. The client under client/ is what reads it.

API reference

Every crate in the workspace is published as rustdoc at the API reference, private items included. It is the place to go when you are reading or changing the code rather than running it.

This page exists so the link is in the sidebar on every page: mdbook treats every SUMMARY.md entry as a chapter file under src/, so a sidebar row cannot point at the site’s own /rustdoc/ directory directly.

The client’s surfaces

The Svelte app that connects to a running forge and draws it. Its surfaces have pages here: read first, sketched with the change, checked afterwards, current state only, and the page arrives in the same change that lands the surface rather than as a follow-up.

Each surface’s drawing sits beside it, in this directory, rather than in docs/mockups/, so a page and the thing it is held against are one place.

The surfaces that exist

SurfacePageDrawing
Connectconnect.mdweb-connect.html
Homehome.mdweb-home.html
A sessionsession.mdweb-session.html
The composercomposer.mdweb-composer.html
The queue pilequeue.mdweb-queue.html
Boardboard.mdweb-board.html
Reviewreview.mdweb-review.html
Modelsmodels.mdweb-models.html
Extensionsextensions.mdweb-extensions.html
Settingssettings.mdweb-settings.html
Filesfiles.mdweb-files.html
A documentdocument.mdweb-document.html
The browser surfacebrowser.mdweb-browser.html
The terminal surfaceterminal.mdweb-terminal.html

web-session.html declares six sections and the client’s plan owned four of them; their pages are session.md and composer.md now, and the fifth - the composer’s blocking states - is drawn by the composer’s own page. The sixth, the diff review overlay the drawing named, landed as a route of its own, review.md, which draws the rounds, the diff its threads are anchored to, and the writing.

web-queue.html is the queued-prompt pile that draws above the composer: the variants it can be in, and the wire’s own lifecycle it follows. It takes the session page’s tokens, its .strip neighbours and its own spine, and queue.md is the page beside it.

The spine

A vertical rule with its items hanging off it is the client’s replacement for indentation and connector trees. Ved, 2026-09-29: “webconnect spine kind of represents what I was looking for from the TUI. I like the tree structure that I used in the TUI, and I’ve been looking for a replacement. I think spine fits very nicely.”

It is a device rather than a screen, and it is already in the design: the chat draws one on a user turn, as an accent left rule, and that is the same family.

Use it where a relationship is a run rather than a nesting, and reach for it instead of an indent. Where it is proposed to go, per surface:

  • The chat’s turn, where it already is.
  • The composer’s dock, where prompt requests queue behind one another and the queue is a short run.
  • The home’s worker rows, as an option rather than a change: the indent under a project says the hierarchy already, and the home has a drawing it is measured against. A spine there would be a redraw, not a device reaching a new place.

The connect screen’s rejected direction uses it too, as the state spine, and that is the same idea applied to a sequence rather than a containment.

Touch

Touch is a hard requirement, not a fallback. The client runs on Android as well as the desktop, so every control a finger reaches takes a 44px target under @media (pointer: coarse) - keyed on the pointer, not the width, because an Android tablet is wide and still finger-driven. A rail row or a home row carries its link across the row’s own height rather than the height of its text, so the whole row is the target. No affordance lives on hover alone: hover recolours a control rather than uncovering one, and the chevron the home row fades in is decoration, not a way in. The rail’s close chip uncovers on hover, and it is the exception that keeps the rule: a touch screen keeps it shown, a keyboard focus reveals it, and its rest state takes no pointer - so it is reachable either way, and a tap on the row lands on the row.

The system bars are the window’s, and the page gives them their band. The Android window draws edge-to-edge under a transparent status bar and gesture bar, and the system claims the bands before the page sees them - taps in the status bar’s, and swipes in the gesture bar’s, which in three-button navigation is fully system-owned. So the shell measures both and writes the four lengths as --safe-*, and every page root - plus the overlays that carry their own top or bottom edge - pads itself by them. On the desktop and the web the four are zero and nothing moves.

Connect

The client’s first screen and the only one without a drawing behind it, so it is also the only one where a choice is a decision rather than a port. The drawing beside this page is web-connect.html, and the home it leads to is Home.

The direction, chosen

Four were drawn and Ved picked this one, 2026-09-29: “I kind of like the webconnect.html vibe more than the prompt spine, or type. Let’s stick with that webconnect.html.” The other three are kept beside it as the record of the choice, the way docs/mockups/web-logo-options-2.html keeps the mark’s.

DirectionKept asWhy it lost
web-connect-prompt.htmlthe recordits own vernacular was the point, and it read as a shell pretending to be a form once a button had to be reachable by pointer as well as by Enter
web-connect-spine.htmlthe recordthe three-step block is honest but it spends the screen’s whole top on a process where the common case is one press
web-connect-type.htmlthe recordthe most minimal of the four, and the address at the sheet’s largest step is thinner than the wordmark beside it, so the page has no first thing to read

What it is for

One developer, on their own machine, who has either just started forge or has not. The screen’s job is to get them to a running forge in one press when the default is right, which is almost always, and to give a legible reason when it is not.

The common case is not reaching it at all. The app keeps the last address it held a connection on and opens on it, so this screen is what a first launch meets and what the launch meets when the remembered address does not answer - and in that second case it opens with that address already in the field and the failure already on it, rather than blank. A failed launch is not forgotten, either: the address stays remembered, because that is the one the reader is about to correct.

A launch may also name a server outright. ?server=<address> - a bare host:port, or a full ws:// URL - pre-fills the field and is tried at launch, which is how a client is opened already pointed at one forge: a scratch stack, a second machine. It outranks the remembered address, and the parameter stays in the URL through every move the app makes - a tab that has been navigated still opens on the forge it was launched at when it reloads. A connection taken on another address moves the parameter onto it.

A launch moves the page only from the root, so a deep link is the one place it takes the connection and leaves the route alone. That makes this screen reachable three ways: a first launch, a launch that did not answer, and navigating here. On its own /connect the door is already drawn when the attempt lands, so the reason arrives on it rather than opening it. A submit wins over a launch, whichever of the two lands first. A launch that lands after a submit has taken a connection yields rather than closing the socket that submit opened, and a launch that lands before it is replaced by the submit when it takes. Either way the page draws the forge the reader asked for and not the one the app was guessing at.

The design plan

Colour. The theme’s own tokens, unchanged: --bg, --s1, --s2, --line, --ctl, --text, --muted, --dim, --accent, --bad. This screen carries no palette of its own, because one theme is the project’s hard scope rule and the server owns the palette. That is a decision rather than a constraint worked around: a first screen is exactly where an identity is usually invented, and here the identity is already the mark and the palette every later page draws with.

Type. Fira Code throughout. The address is data, and this house sets data in the mono face; with one face shipped, prose and data read the same.

Layout. One centred column, four rows, all left-aligned to the column’s edge.

  |  mark forge
  |
  |  127.0.0.1:8790                          mono, 18px, editable in place
  |  ----------------------------------      hairline: accent on focus, bad on failure
  |  the port forge serves on by default      dim, one line
  |
  |  [ Connect ]
  |
  |  this app draws a forge running elsewhere  dimmest

Principles.

  1. The address is the headline, not a form field. An address is what this screen is about. Set as the largest thing in the mono face and edited in place, it reads as the subject; a bordered input would read as paperwork.
  2. Nothing moves between states. Only the hairline’s colour, the line under it, and the button’s label change. The reader’s eye stays where it already was, which is what a screen with three states needs.
  3. One action. Editing the address in place is the whole of the uncommon case, so there is no second control.
  4. No decoration that is not the theme’s. The mark is the only graphic, and it is the same one every page draws.

The three states

StateThe underlineThe line under itButton
Idle--linethe port forge serves on by defaultConnect
Connectingan accent segment running its lengthasking this address for its projectsConnecting, disabled
Failed--badwhy nothing answered, and what to checkTry again

The underline is one element doing two jobs, which is why no second graphic appears in the connecting state: it is already the line under the address, so it carries the progress rather than a spinner being added beside it. Under prefers-reduced-motion it stops moving and goes solid, which says the same thing without motion.

The failed line names the address it tried, and when nothing answered it points at [server] enabled in forge.toml: a forge whose owner turned the socket off refuses in silence, with nothing wrong at either end, and a generic “could not connect” sends the reader off to look at their network.

A forge that answers and speaks a different protocol raises the notice, not a failure. The connection is live, so the unreachable hint would send the reader to look at their network and their forge.toml for a problem that is neither. The line is the skew sentence itself: the command that updates the stale half, this client’s build, and the answering build where the greeting carried it - a server ahead carries it, and an older one may predate it.

The connect presses through to the home either way, and the door says so while it is up. Nothing is refused over a version, so a connect to another protocol lands on the home; and while /connect is up over a connection that is skewed - the reader went back to the door - it draws a one-line notice above the field naming what the wire carries: the command that updates the stale half and this client’s build. It is a notice rather than a failure, so it takes the failure block’s shape with the alarm taken out rather than the --bad rail: nothing has to be fixed for the page to keep working.

What the first pass got wrong

The first attempt drew the mark large and centred with the address in a boxed field below it, and a “use a different address” link that revealed the field on demand. Both parts are the landing-page reflex: a big logo, then progressive disclosure of a form. The subject is a one-field utility, not a landing page, so the address took the hero slot and the field stayed visible. The mark shrank back to the size it is on every other page.

What the audit found

All four directions were put through the UX checklist before they went anywhere, and two findings came out of it. Both were in the sheet rather than in a drawing, so both are fixed there and every surface gets them.

No focus ring anywhere. The sheet defined no :focus-visible at all, and the one rule that touched focus removed it outright, so a keyboard user could not see where they were. It now carries one accent ring with an offset on every control it can reach. The composer’s field is the one control that draws none of its own: focus takes the box’s own ring, which is the accent while the keyboard is in it and the box is at rest, so a keyboard user still sees where they are. Checked by tabbing through a real page rather than by reading the rule: a row’s link draws a 2px accent outline at a 2px offset.

No minimum touch target. The by-width checks measured the layout and never the reach, and the smallest controls were 20 to 28px tall against the 44 a finger needs. Every form control now takes 44px at the phone breakpoint, where before nothing did.

The contrast pair was measured rather than trusted to the token’s name, and it found a third: --dim carried 12.5 and 13.5px labels in about fifty places while measuring 3.51:1 on the page and 3.03:1 on a raised card, under the 4.5:1 that normal text needs. The uses were not wrong, the value was, so the value changed: #788294, which is the darkest step that clears AA on every ground the sheet puts text on, so the change is the smallest one available rather than a comfortable one.

Measured from the rendered page rather than from the two hex values, because the ground is a composite: the body carries a radial gradient over --bg and a card carries one over --s1. Sampling the painted pixels and reading the text colour the browser paints with:

Pair, as renderedRatio
--dim on the page5.26:1
--dim at the gradient’s edge5.25:1
--dim on a card4.98:1
--dim at a card’s deepest4.87:1
--muted on the page7.01:1
--text on a card12.23:1

And looked at, because the number is not the whole test. The three text tokens still rank as a ranking: text, then muted, then dim, at both widths on the home and on the connect screen. The gap between muted and dim is narrower than it was - which is what passing costs - and there is no darker step available, because a darker one fails on the raised card. The two constraints meet at this value.

The keyboard and touch checks were clean on all four directions once the ring and the target size were in, and every direction used the theme’s own tokens, so none of them needed a palette of its own.

Home

The client’s first page: the fleet - one row per project forge.toml declares, carrying the strongest state among its seats, its tree, its counts and its named misses. Nothing is mixed: a project’s own world opens as its board, and the home stays the glance. It is drawn from one snapshot of the home subject, and redrawn from the update stream after it; the page’s own 30-second tick moves the marks derived from the clock. The drawing it is held against is web-home.html, beside this page.

The app opens here whenever it has an address that answers - the one it last connected to - and on the connect screen when it has none, because its only input is the server URL. A deep link is the exception: it keeps the page it was addressed at and is handed the connection, since a session URL is how a seat stays reachable. Until a server answers there is nothing to draw, and the client never falls back to bundled data.

What it draws

RegionShowsRead from
Stoppedthe core’s last fatal, above everything else: forge stopped: and the core’s own wordsfatal_error
Upstreamthe core’s held service status, above the band and only when there is one: claude status and the status line’s own words, in the warn tone or the bad one an error report takesservice_status
Headerthe brand mark, forge, and the three builds as labelled pairs - server (the forge serving the socket), client (this app) and claude (the CLI both drive) - with socket and its protocol beside them; each update notice sits on the build it belongs to (in a browser, the latest published), and the fleet totals close the lineClientSettings.mark from the greeting; forge_version_short; CLIENT_VERSION; PROTOCOL_VERSION; cli_version; the shell’s update check, or the served latest.json; agents, fleet and projects counted
Bandsix cards: the gateway listener, the client’s own address, dictation, the account pool, the extensions, and the settingsaccounts.gateway, the connection the client made, dictate.snapshot, accounts.loading, the extensions subject
Fleet sectionsup to three sections, the rail’s own: needs you, working and asleep, each appearing when it holds a project and carrying its count; the asleep section starts expanded here but folds behind its count when closed, so the page can put the quiet half awaythe same rank the rail groups by, read from each project’s strongest seat - an ask first, then a run, then a sleep; a decision wait is a mark on the row and never a rank
Fleet rowone per project: the strongest seat mark, the project, its branch and changed-file count, when it last moved (the newest write among its seats and the sessions its catalog remembers, now/3h/2d, or never for a project with neither a seat nor a session behind it), its seats, live of cap slots · queue, the named misses, and the ways in - the row itself, each seat, and the boardfleet for the counts and the misses; projects for the tree; agents and projects[].sessions for the seats and the age
Decision-wait marka glyph and a count on a project row and on each seat row that holds a wait, never colour alone. Opening it lists the wait’s seat, its row’s subject, the ask’s own words - the row’s detail, the look a park is owed, the subject when the ask carries none, or the board’s sentence when even the subject is blank - and its age, and each item opens the project’s board, where the row waits and can be answered. Opening a seat does not clear a wait: it ends when the row leaves waiting, and the mark goes with the read that drops itfleet[].attention

The fleet reads in the same three sections the session rail draws, ranked from each project’s strongest seat: a worker holding an ask lifts its project into needs you, and a project with no live seat ranks asleep. A decision wait does not move a row (Ved, 2026-11-03): a task wait is often future work and its seat can be asleep, so the mark and its count stay on the project’s row and on the seat’s chip while the row keeps the group its own state names. The asleep section folds, like the rail’s, but starts expanded on this page - the rail keeps its own folded, because there the fleet is a column beside the conversation rather than the page itself. Its count rides the heading, so a closed section cannot read as an empty one.

Every cell of a fleet row is the server’s own read. The mark is the strongest of the project’s seat states, drawn with the same shapes a seat row uses - or, for a project holding no seat, the rail’s dormant mark: asleep once the catalog remembers a session, never only when nothing has ever run there; the counts are the server’s (slots is the project’s resolved worker cap); and a miss is named rather than implied - a queue stalling with a free slot, a worker holding no row, or a project that cannot start, which is the refusal the row drew before the fleet. The whole row opens the project’s lead in chat - the name’s link stretches over the row’s area, while a seat chip opens its own seat and open board opens the board. A project with no live seat is started by that same click, as a sleeping seat is from a session page’s rail.

Four cards are ways in. The dictation card opens the models page, where the pins, the catalogue check and the feed are drawn; the extensions card opens the extensions page, where the plugin inventory, its components and the marketplaces are; the settings card opens the settings page, which a desktop shell cannot otherwise reach because it has no address bar; and the server card opens the connect screen (below). All are links at rest - a chevron on the card’s title line, drawn dim rather than uncovered by the pointer, because an affordance only the hover uncovers is one a touch reader cannot find - and all take Enter and Space. The other two cards, the gateway listener and the account pool, open nothing.

The extensions card reads its own subject. What a reader wants on it - how many plugins are installed, and whether any is behind its marketplace - is in the extensions read and nowhere on the home’s own wire (the home snapshot carries only the update records), so the card subscribes that subject from the home and takes it with the page. Its states are its own: reading before the read lands, unread for a refused subscription or an inventory with no plugins root to scan (with the core’s own reason on the page behind it), a warning when something is stale, naming how many updates wait, and quiet when nothing is, naming the marketplaces the card draws nothing of.

The header names each build it draws. server is the forge serving the socket (the build in forge_version_short), client is this app’s own build, socket is the protocol they speak, and claude is the CLI both drive. The labels keep one build’s version from being read as another’s, and each update notice sits beside the build it belongs to.

The band’s server card is the way back to the connect screen (Ved, 2026-10-12): the card names the forge this client is on - host:port, as a reader recognises it - and the whole card is a link to /connect, the other door cards’ own pattern. It is how the client is pointed at another forge - a scratch stack, a second machine - without a relaunch.

The row’s tree cell is the project’s own read. It carries the branch the tree is on and how much has changed in it; a count of zero draws nothing, because an unchanged tree is what the cell already means when it is empty. A tree that could not be read says so in the cell’s place - not a git repository, its working directory is not there - because a blank branch beside a real project reads as a tree with nothing to say rather than as one that could not be asked.

The state a row carries

The state is decided by the server and arrives decided. The client promotes five cases of its own before drawing it - background work, a turn nobody watched, an unanswered ask, a failure the seat has not been shown since, and a session that ended and has not been shown since - and draws it as a shape as well as a colour, so the row reads without colour:

StateClassMeans
Runningrunninga turn is in flight, or background work is running
Spawningspawningthe subprocess is coming up
Restartingrestartingthe subprocess died while it was up, and forge is between restarts
Idleidlealive, nothing in flight
Finishedunseena turn completed on a seat no client was showing
Needs youneedsa question, a permission prompt, a Slack draft, a browser, terminal or document hand-off, a held configuration write or a held vault call is waiting
Sign-in neededauththe bridge is waiting on /login
Failedfailedthe session is not running: its spawn never came up, or it died and nothing brought it back
Session endedfaileda session on the seat ended, and the seat has not been opened since
Turn failedfailedthe newest turn ended in failure, and the seat has not been opened since
Asleepasleepthe seat is not running, and no failure is recorded for it
Never startednevernothing has ever run in this project

A project that cannot start does not draw a state of its own: the refusal is one of the named misses instead, which is the same fact placed where a miss already lives. A row that failed carries its reason as a line under it, and so does a row a session ENDED on - the lifecycle beside the mark is the truth about now, so a seat that came back is counted live by the fleet and the header and is somewhere the close walk can land while the mark and the line say what happened; the mark goes the moment the seat is opened. A failed TURN has no reason text of its own (the failure’s words are in the seat’s conversation), so its line is the words a turn failed. A seat between heal attempts reads restarting (N of M) - the attempt forge is on and the cap from [sessions] heal_attempts, or restarting (attempt N) when the server names no cap - under the project’s name, and beside the seat on the rail: the mark says the seat is working, the line says what is happening to it, and nothing about it is the reader’s to act on.

The states the page can be in

  • A fleet, which is the ordinary case.
  • A stopped forge: the core’s last fatal draws above everything, in the core’s own words. It reaches this page from the snapshot’s recorded fatal, so the attach that sees it is one landing inside the wind-down window before the process goes; the live announcement is the fatal’s own frame, drawn in the chat.
  • An upstream issue: the core’s held service status draws above the band, in the status line’s own words. It reaches the page from the snapshot the same way - the probe fires once, before a client attaches, so a page that only took the report off the update stream would draw nothing for an outage that was already up. The live announcement is likewise a chat row, one per report. With nothing held, no banner draws.
  • A fleet mid-change: a row can move between any of the states above, and the page redraws from the stream without the reader losing their place.
  • Nothing configured: no projects at all draws the empty state, naming forge.toml and the [[orgs.projects]] entry that would add one. A page that drew an empty list here would read as broken rather than as unset.
  • An update available: the header names the published version when npm has one strictly newer than the installed CLI. Both sides have to resolve for it to appear at all, so a probe that answered one of them draws nothing rather than claiming an update it cannot see.
  • This app’s own update: the header draws ↑ vX available beside the client pair when a newer release is published, checked once at launch. The line starts as checking for updates... while that check is in flight
    • on the phone it is a network fetch - and settles to the offer, to nothing on a build that is current, or to the failure line below. It is a control, where the CLI notice beside it is not: clicking installs the update and the line becomes ready - restart to finish, which is the click that swaps into the new build. On the phone the same click opens the release page in the browser, where the APK is installed by hand; the line goes back to the offer, so a second tap reopens the page. A failed install draws - update failed, retry. A check that could not run draws update check failed - <reason>, in the shell’s own words and as text rather than a control: a build that is current and a check that failed are different facts, and the reason is what tells them apart.
  • A browser tab: outside the shell there is nothing to install, so the header draws the two facts instead - client vX, the build it is serving, and latest vY when the app it was served with carries a manifest naming a newer release. It is text, not a control: the next load carries the new build. A page nothing serves a manifest to - the dev server - draws the build alone.
  • Connected, waiting for the first read: the door has gone and the fleet has not arrived. The server builds a home snapshot by reading each project’s working tree, so the window is not instant, and it is its own state rather than the connect form handed back - a form here would re-render with the address reset and a live button, and a second Enter would open a second socket. A read the server does not answer is asked again once its bound runs out, so the state ends on its own rather than only when something else moves.
  • The home was refused: the server turned the subscription down and its own words are drawn. The address is not the thing that is wrong, so the door is not what is shown.
  • A home this client could not read: the snapshot arrived malformed, or the store reports it ready and holds nothing. The page names the reason in the fleet’s place rather than drawing an empty fleet, which would read as a forge with no projects. Nothing periodic retries it: the next update that asks for a read, entering the page again, or a reconnect, reads again.
  • A connection that dropped: the page keeps what it last read and says so above it, and the notice stays until a fresh read lands rather than clearing the moment the socket reopens - the window between those two is the one place a reader cannot tell stale rows from current ones.
  • A forge on another protocol: the shell draws one line above the page naming the command that updates the stale half and this client’s build - and the answering build where the greeting carried it - and the page behind it is live. Nothing is refused over a version: the notice is the whole answer, drawn wherever a surface meets the skew. It is the one the shell stands down on at the door’s route, where the door draws its own copy in its own column.

The unseen mark is drawn from a read of its own. unseen is the list of seats whose last turn finished while no client was showing them, and nothing in the records reconstructs it: a turn that ended before any client opened the seat leaves nothing in the transcript to say it went unwatched, so a page reading only the messages would draw every settled seat as idle. The server keeps that fact and sends it; the client draws it and computes none.

Watched is not the same as subscribed. This client keeps every seat it has visited subscribed, so the seat’s frames keep arriving after its page closes - but a subscription is not a showing: the client tells the server which seat is on screen, and that is what spends the seat’s diamond and failure mark. The death trace is spent by the seat being shown, or by the reader’s OWN prompt - a cron’s, a peer’s or forge’s own does not count as looking. A turn that finishes on a visited seat nobody is drawing therefore arms its diamond, and opening the seat spends it; the subscription is what keeps the return instant, not what clears the mark.

Two things this page does not draw are the schedules a project holds and the account a row chips. Each is drawn elsewhere: the schedules on the session page’s strip, whose schedules row keeps the seat’s own set (a cron names the seat that created it) - read from the board subject since #2517, which is also why the home’s own read is a few kilobytes rather than the board’s hundreds - and the account on the session page’s rail footer, beside what it is costing. Neither is a gap in this page.

The two widths

The page is checked at 1600 and at 430, against the drawing beside it. At 430 the band becomes a two-column grid, the header wraps to two lines, and a fleet row’s cells flow onto second lines under the project name; nothing overflows horizontally at either width.

The sheet’s own breakpoints are at 900, 760, 700 and 560. This page’s own collapse is the fleet row’s at 900; its chrome’s is at 760, where the header wraps and the band drops to two columns. Nothing scoped to this page sits below that, so the 430 check covers what the sheet changes here.

A session

The seat’s own page: the conversation in the middle, the projects rail beside it, and at the foot the surface the queue pile and the composer share. It is addressed by the seat’s slot - /session/{org}/{project}/{label} - so a seat is a URL a deep link can reach; /new and /resume swap the occupant and leave the slot alone.

It is drawn from one read of the session subject - a snapshot, then the updates that move it - plus the home’s snapshot for the facts that ride a project’s row and the board’s snapshot for the row’s task, its schedules and its connector sets. The drawing it is held against is web-session.html, beside this page.

What it draws

RegionShowsRead from
Railevery declared project grouped by the strongest state among its rows, the current seat marked, each project’s and seat’s decision waits marked with a glyph and a count, sleepers folded - a fold hiding a decision wait opens itself, so the mark is visible without expanding it; a seat row’s own line names what happened to it - a turn failure, a death with its reason, a refused spawn, or restarting (N of M) while forge brings the seat back - and the footer carries the account and what it is costingthe home’s snapshot, the board’s snapshot for each row’s task, and accounts
Headerthe chip, the seat’s own mark, org and name, and the facts: session id, model with its effort, mode, context fill, and the compaction countheader, conversation.compaction_count
Conversationthe turns and everything inside them, in the column’s own scroll viewportconversation
Queuethe prompts waiting, drawn against what is runningqueue, queue_ended
Barthe composer’s one bar: a live row of the seat’s segments and the running turn’s figures, and a body holding the field, an ask, a take or a blocked linethe seat’s record, the project’s board row, the page’s clock, and the composer’s own record

The chip is the one projects control, and its click is smart. It counts seats that want a person - a question, a permission, a failed turn, never a mere change - and exactly one, not an error, goes straight to that seat; several, or any failure among them, opens the home to pick from; none goes home too, from the quiet projects word. Hovering it summons the rail as a peek. It is a real anchor, so keyboard, middle-click and new-tab all behave. A worker’s row in the rail names the tree it runs in when a lead pointed it at one of its own: the path alone, a little under a failure’s sub-row, hung under the worker’s own name so that name stays whole on its line - and on hover it slides left to show whatever the rail was too narrow for. Its branch and files reading a tree other than the project’s is then expected rather than a fault.

The header reads the seat, not the app. The name leads and the org qualifies it only where the fleet makes the name ambiguous (#1707); the session id is shown in its slot; effort rides the model it belongs to, because it is a property of that choice; the context figure is one unit - how full the conversation is, and how many times it has been cut - and the compaction count is a control that takes the reader to the latest cut. A usage nothing has reported draws no track and a dash, never a bar at zero. Everything the row must fold stays one tap away under more.

The run of rows

Every call, message, thought and notice draws on its own row, in the order it arrived, and the list only appends: no kind gets a row of its own, the kind’s glyph rides the row that draws it, and nothing rearranges under the reader. A message is a tool row and so is a thought - the same rows and marks a run of calls draws - which is what makes traffic and reasoning read as one system.

  • A family row carries a tool family’s glyph: read, search, bash, web, lsp, skill, tool search, config, worktree, tool, and the one mutation row every edit draws whatever tool it was. An lsp call names its operation and its subject - the file it ran on (hover · /Users/vedhavyas/Projects/forge/client/src/chat/families.ts, with the line it asked about when it named one), or the query a symbol search looked for (workspaceSymbol · renderGroup), because the glyph already says lsp.
  • A forge call draws a card for its family - tasks, agents, cron, documents, gotify, handoff, review, slack, terminal, vault, voice - and the System One decisions draw as their own family, so a reader scanning for what the session decided does not find the three decision calls mixed among its cron and peer calls. The handoff’s exit call is its own family: its family is handoff, it wears the handoff mark rather than the workers one, and it carries the note it handed over. A terminal call is the terminal family’s: it wears the terminal mark, titles itself by the terminal it acted on (opened terminal 3, ran a command in a terminal), chips the shell’s state in words (running, waiting for input, exit 2) and quotes the screen or the command’s own text; a parked terminal__hand_off says it waits for the person and carries the ask’s reason. A vault call is the vault family’s: it wears the key mark, titles itself by the item it touched where the call names one (created login 'bank', moved item ... to the trash, searched the vault for 'bank'), chips a parked call waiting for your approval and a find by its count, draws a search’s rows and a folder list’s names, and draws the one value a reveal asked for - the card replaces the raw body, so a value left out of the card would be a value the reader cannot find. A settings call has no success card, so its open is the payload the tool answered with, unchanged: collapsed it says which way the call went and what it touched - settings · read gotify.url, settings · write gotify.url, client.theme, settings · write 4 keys (one or two keys are named, more are counted) - and a write wears waiting for your approval while it parks on the dock, declined if the reader turns it down. A slack call whose answer the card does not dress - the token’s identity, a conversation’s history, a thread, a user search, a transferred file - says what it read (read the history of C1, searched users for 'ved') rather than the family’s own noun, and an attachment carries its direction and its tense: uploading 'brief.pdf' while the dock still holds it, downloaded attachment F1 once the fetch answered.
  • A browser call is the browser’s, whatever server name carried it.
  • A peer message draws a card saying which way it went: sent to and from name the counterparty and its org, failed carries the delivery’s own reason, and whoami and list open onto their facts.
  • A delivery - a cron fire, a Slack message, a Gotify push, a voice note - is a row of its own kind, a cron fire titled by the schedule that fired it and a voice note by the note’s own first line. A Slack row whose block is late adds its age to the title, so a week-old message reads as history before the row is opened.
  • A notice is a tool row, in the run’s own inset. An info, warning or error line - a rate-limit window, an API retry, a turn that failed - draws like a call: a glyph carries the level (a circle, a triangle, a circle with an exclamation, so the shape says it and not only the tone) and the level word is gone, the wire’s own type and a retry’s attempt ride it as chips, and the delay rides a tail. Its words are the message, so they wrap and keep the line breaks they arrived with where a call’s title clamps. A plain notice has nothing behind it, so it is a static row - no chevron, no body, no pointer; a provisional one opens onto its facts and the frame.
  • A nudge - a chase warning, a worker’s kick - is the machinery’s own words, so it draws as a work row under the board’s own mark rather than as the reader’s: nobody typed it, and it never wears .mine. A long one - a user-wait nudge - opens on the whole of what it said, the way a thought row does. The row is found by the id the machinery minted for the prompt, which survives into the transcript, so a reloaded row draws what the live one did.
  • A session start is one row, not one per hook: the firing says what fired and how many hooks it carried, and opens onto them. A hook that finished without a word draws no row of its own inside it; one that failed or is still running stays, and a failed start counts its failed hooks in the closed row’s words as well as taking the failure tone. Every hook still opens onto the whole of what it printed, a start whose hooks all finished silent opens onto a line saying so, and a hook outside a start keeps a row of its own.
  • A read draws by what the file is, from the same per-format policy the Files reader uses: markdown renders as prose, code stays coloured with the file’s own grammar (a Justfile is just, a script’s shebang names its own), and a text file no grammar names draws plain with a caption saying so.
  • An edit draws its diff the way GitHub does, inside the same panel a read draws - the panel’s border and ground, with the format on the head when the file names one: the hunks with both line numbers, the file’s syntax colours on every line, the row tinted for a removal or an addition, and the changed words of a paired removal and addition tinted darker. A change the call’s own input carries as two sides draws the same way without positions: the lines the two sides share stay as context, and only the lines that differ are removals and additions - save a change too large to compare, which draws as one removed block and one added block. A markdown edit renders instead: the hunk draws as markdown, in that same panel, with removed words kept inline, struck and dimmed, and added words drawn normal; the source diff is not shown under it. A markdown file the call created renders as the document it is, with nothing marked: the change is the line-zero hunk a create writes, and the marks line already says the file is new. A plain row - a command’s output, a text result - takes the same panel’s border and ground, with no format strip; a hook run’s output keeps the sheet’s own inset rule. An edit or a question whose structured record the page could not carry says so on its marks line: an omitted figure with its size, scaled to the unit the size belongs to (omitted, 5.0 MB). On an edit it is one more mark beside the hunk count; on a question, which has no hunk count, it is alone on that line. A record over the cap first keeps the keys the row draws and drops only what nothing reads, so a large edit keeps its patch and a Write draws the file it created from the call’s own input, and a question whose huge typed notes pushed it over keeps its answers. A question answered live and the same question read from the transcript draw identically - the cap bounds both paths the same way - so the answer card is the card either way. Only a record with nothing drawable left falls back to the omission: an edit names it on the marks line, and a question draws as its own row, its question glyph and the question it asked, opening on the omission.
  • A backgrounded call is held open by its task fact, its result saying the command started, and the ending landing later as a frame of its own. Its output box follows a call that is still out: while the row stays open and the call runs, the row re-reads the command’s own output file on a one-second tick, and the tail it draws carries a live mark saying the box is still being written. A read that found nothing while the call runs says the output has not appeared yet: the file is there from launch and reads as empty until the command’s first line lands, so the settled wording about a call that never recorded one would be a claim the row cannot make yet. Once the call settles the mark goes and the tail is the finished output. A call the TaskStop tool stopped has no ending frame of its own: the tool path sends no task frame and no registry change, so its own success answer settles the call - in whichever held turn the call sits, which is not always the turn the stop lands in - the row states stopped beside its title, and the processes row stops counting it. A stop whose call the page does not hold draws as a row of its own, named with the task it stopped. The box draws the command’s own colours: every terminal box reads the escape sequences a command wrote - colour, bold, dim, italic, underline - into the theme’s palette, so a coloured compile looks like the terminal’s. A sequence the page cannot draw (a cursor move, an erase, the extended colour forms) is dropped rather than printed, and a command that wrote no colour draws in the box’s own tone.
  • A message’s image draws as the file row a turn attached does: the picture named, and its source printed beside it. The page fetches nothing for it - a message is text someone else wrote, so the host it names is never asked for.
  • A settled failure with nothing to open onto says so in words rather than drawing an empty body.
  • A frame the page has no face for yet draws as itself, marked provisional. An unknown top-level frame and an unknown content block, and the CLI’s own system lines for an API error, an informational line and a notification, each draw as a notice row whose level is its glyph and whose chip is the wire’s own type. The open body lists what the frame carried as one-line labelled values - an error’s message, status and retry, an unknown frame’s fields - and the frame itself sits behind a second control under them, so the payload stays reachable while the row’s face is the facts. The frame is seen the day it arrives and can be given a real face later, rather than being dropped. A permission refusal draws on the call row it refused, which no result frame will ever settle: the row’s chip is the wire’s own permission_denied and its body carries the CLI’s reason where the result said nothing, so one refusal is one row. A refused mutation keeps its diff and the reason rides under it, because the diff comes from the call’s own input rather than from the result. It draws as the notice above only where the fold holds no call row for it - a page opened after the call, or a peer message drawn as a card, the one card a call never also draws a row for - and the refusal is never dropped. A sub-agent’s refusal draws inside the dispatch that owns it: the frame names the agent rather than the dispatch, and the server walks the agent’s dispatch chain up to the top-level card - a second level’s call is a child line of the card and a third level’s names that child line, so an agent two or three levels down still resolves to the expansion that draws it. The refusal rides that expansion’s call row with the same chip and body, or draws as a notice line there - the level glyph and the wire’s own chip the session’s notice carries - when the expansion holds no call row for it. A refusal no instance owns - a task the roster never linked - stays at the session’s level. A resumed page draws a sub-agent’s refusal from its own sidecar, as the call’s errored result, and a nested sidecar’s frames fold under the expansion that owns them there too: the fold resolves the chain from the sidecar metas, so a parent’s body past the resume budget does not strand its child, a sidecar whose own body is past the budget is skipped, and a sidecar whose chain reaches no dispatch the transcript answers joins nothing. A question draws nothing here while it waits: the dock is its row. It draws as a card instead - the words the reader was asked, marked unanswered, with nothing picked or typed under them - the moment its own result lands with no answer on record (the reader dismissing the dock), or the turn closes on it. A question whose result came back an error, or one the CLI refused before it ran, keeps its call row instead.
  • A model swap, a memory recall, a local command and a project’s development intent each draw a row of their own. A model fallback (and a refusal retry, a refusal with no fallback, and the Fable consent gate) draws a warning whose words are the CLI’s own content - or, for the refusal retry, which carries none, a line saying the session changed model - and whose open body names the trigger, the model it left and the model it fell to. A memory recall draws one line saying how many memories the turn was given, with the paths and, where the CLI supplies it, the recalled body in the open body rather than one row per memory. A local command draws one row: the command it ran, with the command’s output in its open body. The CLI sends the name and the print as one frame (2.1.280) or two (older), and a print fills the row the name opened - so a command is one row and not two, and there is no assistant message carrying the output, because forge reads the raw system frames. A project development-intent frame draws one quiet line naming the platform the CLI saw, with the evidence in its open body; the CLI’s emit cadence is not captured, so the line is a fact about the project, not a count of turns.
  • The CLI’s own repository events belong to the working-tree chip, not the conversation. A vcs_state_changed frame is the tree’s own news: the tree’s chip on the composer’s live row says what moved for a moment - the kind and, where the CLI attributed it, the branch - and the tree row re-reads on the frame rather than at its next staleness window, so no chat row is drawn. A code_change_published frame keeps a chat row only for the four transitions a reader would otherwise miss - created, merged, closed and reopened - naming the action, the repository and the change number, with the URL a reader opens as the quiet tail; every action rides the chip’s transient line, and the PR row carries its state after the core’s own lookup, so an edit, a comment or a checkout is not silent - and the four that keep a chat row ride both. The live row is the shown seat’s: a mutation on a seat you are not looking at is not drawn, its frame belonging to the seat but the line to the one on screen. A chat row’s open lists the frame’s own fields, so a fact the line does not spell is still reachable. Both are events from the live window: the CLI writes neither to the transcript, so a conversation reopened from it shows the tree and the PR row, but not the transient line.

The conversation is cut by the server into turns, and the client folds each turn’s frames into its rows; a compaction boundary draws where it happened, collapsed, with the header’s count saying how many and the mark saying where. A settled turn ends on its turn row, and a failed turn on the failure line just under it; a message that arrives at the turn’s end, a peer message among them, draws below either, where the next turn starts.

A handoff opens a new chat. When forge replaces a session with a fresh one that continues from a brief - at a turn boundary past 85% of its window by default, after a compaction, or when one is asked for - the successor’s transcript opens with the brief forge delivered, and that prompt draws as the handoff procedure: the row wears the handoff’s own mark, names itself, and holds the whole brief behind its open. The brief is the ground truth forge read off the transcript, git and the board - the seat, the working directory, the branch, the role, the files read and changed, the rows the seat owns and their links, the transcript the record names, and what was still running when the seat changed: the live agents, background tasks, monitors and the session’s process tree, so the successor knows what to resume - then the note the handing-over session wrote, or its closing text under a source label when the exit call carried none: where things stand, what is in flight with the id that names it, what failed, and the exact next action with the file, command or row it starts at, so the successor opens what the note names rather than reading a retelling of it.

The previous conversation goes with the seat that left. The chat clears on the swap rather than carrying the old turns above a seam: the successor is a new session with its own transcript, and what it draws is its own. The handoff record keeps the old session’s id.

A row is drawn before the list has measured it. The virtualised list places a row on an estimate-derived offset and corrects it the moment the row reports its own height; drawn rather than left blank, so a fast scroll through fresh history reads as moving content instead of a black column. The correction is the list’s, and the column’s foot and parked anchor absorb it, so the place the reader holds does not move under the reader.

A conversation with a saved place opens at it; everything else opens at the latest. A place is the row the reader held and how far into it they were, kept per conversation - the seat plus the claude session id it runs as

  • in memory for the app run. Any page that unmounts the session, such as a document, the review, the files, the home or the settings, leaves the connection keeping the conversation, so the mount that comes back puts the reader on their row; switching away to another seat and back does the same, and so does a seat reopened on a new connection. A new connection’s first page holds the newest turns, so it can start below the reader’s row; the restore asks for the pages above it until the list reaches the row, and a page that never carries it inside the restore’s budget of about a second leaves the reader at the latest with the place kept for the next visit. A place the conversation no longer holds - its row taken by a compaction or a trim, with nothing left to load - goes to the latest rather than clamping to whatever row is left. A replacement that names the SAME claude session id is that conversation reloading, so the place survives it - unless the place was recorded before any id was known, which goes with the first id announced. A place the reader gave up, by a send from this client while the column is drawn or by scrolling back to the latest, is not restored; a /new or a /resume to a different claude session id is a different conversation and opens at its latest (#1673, #2834).

A parked column is frozen. The moment the reader scrolls up, the row under their eye holds its exact offset - sub-pixel included, so a reflow above does not move the words they are reading - while the scrollbar grows with what lands below. Coming back within the 48px notch of the bottom on a hand or a gesture aiming at the foot follows again; a layout move that happens to land inside the notch counts as standing at the foot and re-arms nothing. The circle in the corner is the instant jump there, and it is a 44px target on every client, finger or pointer. The circle counts what was missed: the turns appended after the one the reader parked at ride a small badge that reads 9+ from nine, and following clears it. Sending from this client while the column is drawn - the composer, a takeover answer, the palette - is the reader asking for the latest, so it goes there and follows; a message the reader sends from another client lands where they are and joins the count, and a nudge from a page that does not draw the column, such as filing a review or answering a document, leaves their place to reopen.

A following column glides to the foot. Every append - a row, a card, the reader’s own message - lands the measured bottom over a short ease of about 160ms, a streamed line and a row that measures taller included, with the glide repeated until the content settles. A first pin, a switch’s or a return’s, lands at once, and so does an entry’s settle until the list has measured the rows already in the element. The circle’s jump lands at once as well. prefers-reduced-motion: reduce makes every landing instant.

The column’s top edge says when it is fetching. An older page is asked for as the reader nears the top and answered a moment later; while it is in flight the wheel has nothing left to scroll, so a small pill at the top edge says the older messages are loading rather than leaving the column reading as a dead scroll. The reader’s own page is sized by what the column has measured - about a screen and a half of its own turns where the turns allow, one turn at minimum - while a walk toward a distant row keeps the full page. A conversation that fits the room has no top to scroll to, so it asks for the page above on the fitting state itself and keeps asking as pages land, until the history fills the room or ends.

The held history settles on the newest window. Reading back through a seat keeps whatever the reader pulls, and the server answers every page below its own window from the transcript under the same [server] more_page_cap_kb byte budget as any other more page, so a page is never a whole transcript read. One turn over the budget still rides whole, so a long walk can still hold more than 8 MiB: when the reader comes back to the latest, the oldest pages are dropped until the held set fits 8 MiB, and a seat the reader has left settles on each page that lands while it is away. Dropping is by whole page and never under the row at the column’s top edge: a page’s rows share the cursor that re-fetches it, so a row cut from inside one would be skipped. The newest page and the subscribe seed stay whole even when they alone are over the window - their rows name the place above themselves, and no more can return them. The first page left carries the cursor that re-fetches it - the one its page was asked with, or the newest page’s own handle when the trim kept that page whole - so scrolling back into the trimmed stretch asks for it again and the column’s top-edge pill says it is loading. Nothing is made unreachable.

The live row

One row of segments at the top of the composer’s card, in a fixed order: the browser, the terminal, the agents, the processes, the connectors, the schedules, the MCP servers, the files, the tree, the review, the tasks, the monitors. Each segment draws what it has and nothing else, and the row stands whenever anything has something to say - a running turn is one of the things it may say, not the condition for it, because these chips are the home of what the inspector used to hold. The row is in the composer’s own card, not a lane above it: the composer page draws its shape and what the body below it holds, and its drawing shows every state.

The browser segment opens the surface, not a window (see browser.md): its panel lists the tabs of the profile this seat’s project resolves to, show raises one of them as the full-window takeover, hide lowers it, and the sessions keep driving the same tabs underneath while it is closed. It draws only while that profile has a tab, so a page that has never used the browser carries no browser row.

The terminal segment raises the seat’s terminal (see terminal.md): show opens the full-window surface, hide lowers it, and the row draws for every seat - on one no agent has opened, its button is start, the person’s own door; a seat whose read is refused draws the note that says why instead.

The files slip is the one mandatory segment (Ved, 2026-10-17): every seat has a tree - a worker’s own worktree, the lead’s checkout - so it draws for every seat with a record, where the tree row beside it keeps its rule of staying away when nothing has moved. That rule does not silence the segment: a repository event with no tree row to ride - a commit on a clean default branch - draws a note-only line for the event’s eight-second window, a status rather than a control, and the segment is gone once it clears. The slip IS the door, with no panel: a press opens the page that reads the whole tree (/session/<org>/<project>/<label>/files), reading walk · files for a seat in a worktree of its own (the worktree’s name, so the destination is never a guess) and plain files for the project’s checkout, with the browser’s own mark beside it and no count. No hover dialog, no panel - there is nothing here but the way in.

The processes row is the seat’s running calls. A call the model backgrounded and a long foreground one the CLI is tracking while it runs both draw, led by the CLI’s own description and joined to the OS walk for the memory and pid its command names. A foreground call settles with the turn that ran it; a backgrounded one outlives its turn and leaves when the CLI’s own registry stops naming it.

Every cell is a read, not a computation. The schedules row keeps the seat’s own set - a cron names the seat that created it - and its countdowns re-derive against the page’s own clock, as the monitors’ ages do; the tasks row reads the project’s slice of the board; the tree row is the seat’s own working tree, the worktree for a worker and the project’s directory for a lead, and a worker a lead pointed at a tree of its own is named by that tree - ~/Projects/nazar beside its project’s siblings - rather than described. A tree that could not be read says so rather than drawing a blank. The account a row chips is on the rail’s footer beside what it is costing, not on the live row: a spend-billed key shows its day, week, month, balance and cap, and a window-billed account shows its 5h and 7d rows, each with its percent, the reset that lifts it, and at limit on the window spending the plan.

The review row’s panel is the review sets. The filed rounds draw oldest first with the latest at the bottom - the conversation’s own direction - each a summary line and a tally of its threads’ states; under it a thread collapses to its file, its line and its toned state, and a click opens the commented code (the branch diff around the line the server resolved the anchor to, or the lines captured with the comment where it has moved on) beside the whole thread, its turns in order.

The states the page can be in

  • A seat with a conversation, which is the ordinary case.
  • Waking: a seat nothing has started. Opening a lead’s page starts it, and a worker’s seat with nothing behind it draws not running and the reason, because a worker is spawned by the lead that owns it. A seat this client just closed is never asked: the roster landing it asleep is that close arriving, not a wake to start.
  • A seat whose session died: the row carries the death with the reason it left, drawn with the failure mark, until the seat is shown - even after the seat is back up, when its lifecycle reads what it is now. The trace is spent by the seat being SHOWN, or by a prompt the reader themself sent; a subscription alone does not spend it, and neither does a cron’s, a peer’s or forge’s own prompt. An opened lead’s page starts a died seat again the way a sleeping one is. What stays parked: a Failed row with no death behind it, which is a spawn that never came up - the reason line says which - and a seat waiting on sign-in, whose lifecycle is its own and is never woken. The recovery that brings a died seat back says so in the chat - the session resumed, or a new one under a new id.
  • A refused read: the server turned the subscription down, and its own words are drawn.
  • A connection that dropped: the page keeps what it last read, and the rail’s footer says so where the build facts live.
  • A forge on another protocol: the same footer line names the skew; the page behind it is live, and nothing is refused over a version.
  • An account the poller has not reported: a window-billed account keeps its 5h and 7d rows with - for a window it has no reading for, and a project whose account chip has not resolved draws the account it declares - or account - with usage not reported. The footer never vanishes, so an absent snapshot cannot read as forge having no account.
  • An account at its limit: the footer still names it and states both windows, because a rate-limited response is a reading rather than an absence. The account line says rate limited beside the name - the dot’s colour alone cannot, and neither can the loading state, which reads ready (or bailed at boot) while the rotation holds the account out. The spent window says at limit beside its percent, in words as well as the amber, and each window states its own reset in the reader’s clock - resets 23:10, or resets Tue 09:00 when it is more than a day out; a reset the clock has passed states nothing, and its window stops claiming to be spent. A window the provider rejects below its cap is marked too: the count can be a request limit, not only a token one. The reading lives in memory only, so a relaunch falls back to the last successful probe - or - where there is none - until the account answers again.

The band is where every narrative state speaks. A seat coming up, the first page still out, a command in flight, a compaction, a handoff and a worker’s not running all draw one line in the composer’s band, with the same sweep and only the text changing; the column draws none of them, and the composer’s own page describes that draw. The column keeps one thing for the read wait: a send made before the page arrived is drawn there as the reader’s own row, so words already on their way are never invisible.

The two widths

The page is checked at 1600 and at 430, against the drawing beside it. The rail is a column by default, remembered per device; the hover mode, or a window below the width a column stops making sense at, gives the chip instead: hovering summons the peek, Cmd+Left or the palette opens it to stay, and Escape or Back closes it. At 430 the chat keeps its single column, the facts row folds under more, and nothing overflows horizontally at either width.

Touch is the rule, not a fallback, and this page is where it is held: the client runs on Android, so every control a finger reaches takes a 44px target under a coarse pointer, and the rail’s close chip is the one affordance hover uncovers - a touch screen keeps it shown and a keyboard focus reveals it, so it is reachable either way.

The system bars are the page’s own edges. The Android window draws edge-to-edge, so the shell measures the status bar and the gesture bar and writes the four lengths as --safe-*; the header row, the chat’s foot and the summoned rail are each padded by them. A tap in the status bar’s band goes to the system before the page sees it, so the chip and the palette’s magnifier - the touch door to the rail - have to sit clear of it.

The composer

The input surface at the foot of a session page: one card whose top row says what this seat is doing and whose body holds exactly one thing - the field, an ask, a take’s card, or the reason it cannot take input. The queued prompts that pile above it are the queue’s page. The drawing it is held against is web-composer.html, beside this page.

The draft is the reader’s own state and this client holds it, per seat: a draft follows the seat, comes back when the reader does, and never walks into the seat being moved to. Nothing the card shows is a bundled default; every list it opens is a live read.

What it draws

PartShowsRead from
Live rowthe running turn’s figures (mark, clock, thinking, tokens, cached, written, cost) and the seat’s segment chips, one line, never wrapping; the ask that waits, and the reader’s draft chipthe seat’s record, the board’s project row, and the segment stores
Bodyexactly one of: the field (with its notice above), an ask’s dock, a take’s card, a blocked linethe host’s composer store, pending_asks, the dictate subject
Menuone popover above the card: the five lists belowthe CLI’s advertised commands, this seat’s file index, the subagent catalogue, the bundle’s emoji set, the sessions this seat can resume
Dialogthe dictation panel: the axes, the models each role runs, the input devicethe greeting’s dictate axes; dictate subject

The live row is the strip, moved onto the card. It mounts the same segment components the strip drew - browser, terminal, subagents, processes, connectors, schedules, MCPs, files, git, review, tasks, monitors - in that fixed order, so a segment gaining a state gains it here for free. A turn’s figure the record does not carry is dropped, never drawn as a zero or a dash. Chips that do not fit collapse behind +N, which lists them in a panel; the row clips rather than wrapping or scrolling sideways. A segment with something waiting - the review answer, a failed process - takes its tone: the accent for attention, red for a failure.

The row carries the ask too. While an ask holds the body, a chip at the row’s front names it (the question’s position, an approval, a held post, a document, a browser hand-off, a terminal hand-off, a held vault call or a held configuration write) in the attention tone. When the reader has words in their draft, a draft N lines · show chip sits on the row: one click swaps the field into the body, the ask stays mounted and hidden behind it, and the ask’s chip brings it back. Nothing the reader had chosen in the ask - a toggled set, the marked row - is lost to the swap.

Every send is stamped. The composer mints a uuid for the prompt and sends the words under it, which is what makes the CLI emit the lifecycle frames the queue page reads; without a stamp the CLI emits none.

The ask’s dock

While a prompt holds the body it replaces the field with its own card. A held Slack draft shows where it would go and the words that would go out, with every mention drawn as the name the core resolved (its id when the lookup fails), as Slack draws it, and its verbs follow what it would do: one wording for a post, an edit, a delete, a reaction, a reaction’s removal and an upload would ask the reader to approve the wrong act. A post draws Post and Do not post; an edit draws Apply edit and Do not apply; a delete draws Delete and Keep it; adding a reaction draws React and Do not react, and removing one draws Unreact and Do not unreact; an upload draws Upload and Do not upload. The destination line follows the same rule: the pill carries the workspace and the conversation, and the segment beside it names the thread a post or an upload replies into, or the message every other operation acts on, by its author and its opening words, with a mention in the quote drawn as its name (its id when the lookup fails), a link as its label or address, and bold without its stars. The people the draft tags follow as their names. A quoted message the core could not read reads as a thread - a message for an operation whose stamp names a message - and a tag it could not resolve draws its id, so a failed lookup is drawn as itself rather than as a missing segment. A message that itself sits inside a thread is not named: the read answers its thread’s parent, so that card reads as a message rather than naming the wrong one. A root message draws no thread segment, and a draft that tags nobody draws no tags segment.

A third answer, send back with feedback, opens a field on the card. Enter sends, Shift+Enter is a newline, and Escape closes the field without sending. The words reach the agent as the blocked call’s refusal, so it revises and calls again, and the revision parks as its own card; nothing posts until the reader approves that one. The press stands the card down, a line where it stood says the feedback is on its way, and a refusal replaces that line with its reason.

The send-back field takes dictation. It wears the same DictateControl as every other text box, so a reader can speak the correction rather than type it. While its take runs the take’s card replaces the field in the send-back row, and the card’s own way out abandons the take. A take begun there lands its words in the send-back field, not the composer’s draft behind the card; so does a take begun with the push-to-talk key while the field is open and holds the caret, because the key names no box of its own. A take whose field closes before its words land keeps them in the composer’s draft instead, so nothing spoken is lost. A take that never starts says so in the dock’s refusal line at its foot, because the box’s own notice row is not on screen while a prompt holds the slot.

A held configuration write draws like a held post, with both values. A settings__set or settings__unset call parks every key it would change, what that key holds now and what the write would leave; one card carries a whole batch, because a section’s mutually required keys cannot be approved apart. The two verbs are Write it and Don't write, and the dock stands down on the answer the way a post’s does. Every write is held, whether the key belongs to this project or another.

A held vault call draws the act, never a secret. The card leads with the tool’s own name and the core’s one-line summary, then names the item and its id, the field the call reveals or changes, the diff with every secret value masked, and the attachment’s name and size when the call moves one. The draft carries no secret value at all, so nothing the card draws can reveal one; the value reaches the tool’s result only after an approval. A card parked while the session is locked carries only what the call’s arguments already say - the item’s id, the field or the masked diff, an attachment’s name and size - because naming the item needs the keys; the name appears on a card parked after an unlock.

Where the device holds a biometric enrolment, the locked card leads with the gesture - Unlock with this device, or Unlock with the device password where the gate’s fallback is the password - with use the Vaultwarden password one link beneath it. Enter runs the gesture, the way Enter submits the other cards. The gesture is the approve: the unlock material it releases rides only an approval, and a send-back or a decline runs nothing. A refused gesture never settles the call: the card stands, says the platform’s own reason under the button, and the password it offers is still one click away. A lock frame turns an open card back into the unlock card: where the device holds an enrolment it offers the gesture again with the Vaultwarden password one link beneath, and otherwise it asks for the password. The frame is the only announcement the idle lock makes: a quiet session with no read in between has no frame yet, so its first approve can still answer Locked, and the call returns on its next attempt as the unlock card. Without an enrolment, or where the platform has no gate, the card is the password one: a masked master-password field and an optional 2FA code, with Approve unavailable until the password is entered, because the approval is what performs the unlock. Its verbs are Approve, Send back with a note and Decline - the gesture taking the Approve’s place where it leads - and the send-back opens the same words field a held post’s does.

The five lists

Five triggers, one popover shape, each row carrying the real icon for its kind rather than a character-cell prefix and the matched span highlighted:

TriggerListSource
/ at the start of a draftcommands, forge’s own leading, the CLI’s advertised following under their own headingforge’s command table in the bundle; the CLI’s slash_commands read for this seat
@this seat’s files, by path from the session’s rootthe file index walk
&the subagent types the CLI namesthe subagents catalogue
:nameemoji, by shortcodethe bundle’s own set
/resume and /resume <partial>the sessions this seat can jump back to, newest first: the id, and the transcript’s own title beside itthe record’s resume_sessions read

The popover is over the conversation, not a lane of the bar. It was position: static in the box, and an open menu took the band from 121px to 557px; it now hangs above the card and the bar’s height does not move with it.

The /resume list is the set that command can actually resume. The argument has to be a whole word, so a second word opens nothing rather than composing a line the core answers as a misuse. A lead is offered its own project’s sessions and never its workers’; a worker is offered only the runs carrying its own label, and only from the directory its own resume runs in. The session the seat is already on is not offered: every row is a real jump. A pick writes the id alone, so the send is the ordinary /resume <id>.

A query drops the headings and leaves the order; a list with no rows opens nothing, because a popover holding only a header reads as a list that lost its rows rather than as a query that matched nothing. The header states the number of matches and a list is cut at a bound on ranking work, so a truncated list never reads as the whole list. Forge’s table shadows the CLI’s row of the same name - one name, one row.

Voice

A second control sits beside the mic when [tts] enabled is set: the switch for the agent’s voice on the seat this client shows. It is off by default. A click turns it on and the seat’s listener count comes back; a second click turns it off. The control draws its state as a shape, not a colour: a struck-through speaker when off, a plain speaker when on, and a mark beside it while a reply is sounding. While more than one client is listening, the switch draws the room’s count beside the icon and its title names it; the count is the seat’s own answer, seeded from the seat read on a late attach.

Listening is a mode on the seat’s agent, not a second copy of the chat. While a client is listening, the agent speaks the substance of each update as it would say it in a live conversation, phrasing it for the ear and keeping it shorter than the page, while the written chat stays the record that carries the detail. The first client in puts a note into the session that tells the agent the person is listening and in what register.

Dictation and voice are different directions. The mic is how the user speaks to the agent: it fills the box with text and stops there. The voice switch is how the agent’s replies are heard: it turns the seat’s speech on or off for this client and does nothing else.

Voice is per client and per seat. Every connection on the seat receives its clips, and this client plays them only while its switch is on. The last client out tells the agent the person stopped when the reader turned the switch off themselves, and when the connection that held that client goes away: a closed client or a dropped socket empties the seat’s listener set too. A seat change pauses the switch and a return to a seat the reader had on reconnects it, so moving between seats does not toggle the agent. A closed client or a dropped socket turns the switch off. The clips play in the order the engine rendered them, each sentence following the one before, and a reconnect discards the queue rather than playing a conversation with holes in it, then resubscribes the switch if this client still wants voice.

Dictation

The mic sits at the body row’s end; a take runs ready (the capture is open, nothing said yet), recording from the first real sample, and transcribing after the release. The take’s card draws in the body’s own row in every phase - a dot while it records, a spinner while it transcribes, the clock, the level graph or the section bar, the counts and the way out - except a take begun in the send-back box, whose card draws in the send-back row in place of its field. There is no second take shape anywhere, no pill or badge on the live row. Push-to-talk is forge.toml’s own binding (right or left Cmd, or off), in one of three modes: a clean press-release under 300ms is a tap, and whether a tap keeps recording or finishes is the mode’s business. The panel opens from the mic: the three axes - styling, structure, context - the models each role runs, and the input device. The axes are per seat and remembered on this machine, so a reload returns what the reader chose rather than the config’s value.

The panel hangs off the input row, not the card above it. The live row changes height with every turn, so a panel anchored to the card would move and re-cap as a turn starts and ends; anchored to the row, the row grows under it and the panel stays where it is. Its models read is held for the connection rather than remade whenever the page hands the composer its props again, so a record frame never blinks the rows back to “reading”.

On the desktop the picked input stays open for the client’s whole run: the shell takes the hold as the page that takes typing appears and again whenever the pick changes, so a press records from the press rather than opening a device under it, and a take reuses the held stream instead of paying the device’s own start-up. The microphone indicator stays lit while the hold is on - the accepted cost - and macOS asks for the microphone as the page opens rather than at the first press. In a browser the capture is the webview’s own, opened per take as it always was.

A held capture the device has stopped serving is opened again. The audio layer under the client changes on its own - Android disconnects the app’s input stream when the route moves, and a disconnected stream never delivers again - so the hold and the press open the device again when the held capture has gone half a second without a block. One such event does not leave every later press recording nothing.

A capture the device is feeding nothing says so while it is running. The row’s chip stays ready and no graph draws for as long as that lasts, so past a few seconds without a sample the take line says the capture is open and nothing is coming through it, and that line goes the moment a sample lands. On the phone the shell’s own readers name the cause where the platform reports one - the phone silencing this app’s microphone, or the device’s own mute - and everywhere else the line asks the reader to check those and whether another app is on the microphone.

An open that the device never answers is reported rather than waited on. The open blocks on the hardware, and a device layer that is wedged - a permission whose prompt never surfaced, a device another process holds - answers neither way. Past twenty seconds the line above the body says the microphone did not come up, and the take the open began is left nobody’s: when it answers late it is ended, so the next press records rather than being refused as busy. A device layer that fails by panicking during the take’s open is answered the same way: the shell catches it, names it on that line (the microphone device layer panicked during the take's open: ...), and writes the operation, the device and a backtrace to the client log. The audio callback has its own guard, in the capture crate: a panic in the pipeline body forge calls from cpal’s frame is caught there before it crosses the C boundary, and the capture stops delivering. That line is a tracing record, and the shell forwards it into the client log beside its own lines. A panic that unwinds through a foreign frame still aborts; no catch at either layer can reach it.

Every landing copies its words. After a take’s words land in a box - the composer’s draft, the dock’s words row or the send-back field - the same text is written to the clipboard with navigator.clipboard.writeText, overwriting what was there. A page over a plain-http origin has no clipboard at all and a live one can still refuse the write; neither costs the reader the words that just landed, and a refusal is logged at debug.

The states the surface can be in

StateDraws
Idlethe field row alone when the seat has nothing to report, the live row above it otherwise
Typingthe draft, the send joining the row; the keys as a pointer’s detail
Runningthe turn’s figures leading the live row; stop beside the mic, and escape its key
A question parkedthe dock in the body, its chip on the live row, the reader’s draft a chip beside it
An ask foldedevery kind folds to one bar: the kind’s mark, its subject, the count behind it, a refusal line
Sign-in neededthe field stays, with the sign-in line above it: the reader is told where, not blocked
A handoffhanding this session over, the sweep, no field
Compactingcompacting this session, the sweep, no field
A command in flightRunning <name> for the slash command the turn is still working on, the sweep
WakingWaking up agent... while the lead’s seat comes up, the sweep
Not runningnot running and the seat’s own reason: a worker’s seat has no spawn coming, and says so
ReadingReading the conversation... while the seat’s first page is still out, with the sweep, no field
A take that does not come upthe body stays, with the line above it: the microphone did not come up, or the device layer’s own named failure
A take that failedthe field comes back with the notice above it, no recording band and no stop control: the take produced no words, so the reader is told to re-speak rather than discover it
A take that landed with a gapthe words land with the notice above the field, this is what fitted · part of the take is missing: the take hit its cap, a segment ran out of decode budget or stopped on a repetition loop, or a segment failed after words landed, so the reader knows part of what they said did not land rather than that they stopped speaking
Voice onthe voice switch beside the mic in the accent: a plain speaker, with a mark beside it while a reply is sounding
Voice offthe same switch, a struck-through speaker and dim, meaning this client is not listening to this seat
FailedInput disabled due to error and what to do: quit and start forge again

A blocked state replaces the body entirely and always says why. The order the states are read in is the one a reader has to be told: a failing seat first, then a seat nothing has started, then the more specific truth about a turn the reader caused.

The read wait is one of them. A seat whose first page is still out says Reading the conversation... and holds the body like every other narrative state: there is nothing to type into until the page lands, so the line takes the box rather than sitting above a live field - a row above the field grew the composer and shrank it again before the chat appeared. A take in flight or a parked ask takes the body ahead of it, because both are the reader’s own surface; while the read wait holds the box the push-to-talk key begins nothing, since the field is not on screen to receive the words. A row the column already holds - a send carried across a reload, or one the takeover bar sent - still draws above as the reader’s own, so those words are never lost. A command the core answers itself draws no row: its answer is the feedback.

Every narrative hold is one draw. Waking, reading, a command in flight, a compaction and a handoff all replace the body with one line and the same sweep moving through its words - only the text changes - so a hold reads the same wherever it comes from. The two states that are not progress carry no motion: not running states a stop, and the error keeps its own tone and its sub.

A machine turn is one the reader cannot type into. A handoff replaces the seat when its judgment turn ends, and a compaction is the CLI rewriting the context; both take the field, the send and the mic away and name themselves in the line. The push-to-talk key begins nothing there, but a take already running still ends on it: the body draws no row to end one from, and Escape would throw the words away. The draft is the reader’s and stays on the seat, so the words come back with the field when the machine turn ends.

A parked prompt outranks the machine line. Both machine turns are ordinary tool-using turns - the judgment turn writes memory files and stops processes - so they raise permission prompts, and this card is the only surface that answers them. The dock draws, the line stands down, and the turn carries on once the prompt is answered; the alternative is a seat waiting for an answer nobody can give. A compaction still running when a handoff ends is read back off the record, so the body does not come alive under it.

The two widths

The card is drawn and checked on the session page’s column, at 1600 and at 430. At 1600 the idle band measures 83px, or 46px on a seat with nothing to report; an open menu does not move it. At 430 it measures 121px: every control a finger reaches takes a 44px target under a coarse pointer, keyed on the pointer rather than the width - the phone has no push-to-talk key, so the mic is the only way into dictation there - and the key hints stand down because a finger has no use for them. The live row never wraps at either width; the figures give way first, and what still does not fit is behind +N.

The queue pile

The prompts waiting in the CLI’s queue, drawn as a pile above the box on a session page. It is the drawing web-queue.html beside this page, every variant of it.

The pile is the core’s, not the page’s. The seat’s read carries it and the stream keeps it current, so two clients agree about what is waiting; the page draws it and dispatches a cancel. A row leaves when the core says it left, so a prompt the CLI has already taken cannot be drawn as dropped.

What it draws

PartShowsRead from
Headqueued and the count, then the key hints: ↑↓ walk, and ⌫ cancel once the walk is on a rowthe queue’s length
Facethe walked row’s words and meta: the source chip, next on the oldest, #i / n while walkingqueue[].text, queue[].source, the walk’s cursor
Arcsevery other queued prompt as one 16px strip of its own card behind the facethe rows behind the face
Endedone dim line where the last card was, with the prompt’s words, for a row that left without being deliveredqueue_ended

One mechanism makes the depth. Every row sits at the pile’s foot, lifted one step per queued prompt, so the rows behind the face show as edges rather than as a list. The face is the walked row - the newest when the walk is in the box - and only the face draws its words. The step is wide on purpose (Ved, 2026-10-04): at six pixels the edges read as one smudge, and every queued prompt keeps a strip of its own.

The arithmetic has no cap. A pile deeper than the room draws deeper than the room, on purpose: a cap would hide waiting prompts, which is the opposite of what the pile is for. The chat column above shrinks by exactly the pile’s height, so nothing paints over it.

The source chip names where a prompt came from, in its own colour: you, cron, gotify, slack, and peer for another session’s envelope

  • whose card draws its body alone, since the envelope’s raw head is plumbing and the sender is the chip’s own word. Forge’s own machinery - a worker kick, an auto-continue - carries forge, which is neither the reader’s nor a connector’s.

A row that left without being delivered says so, in its own words. discarded, never sent: <words> and refused by a hook: <words> are the two endings the core reports; a row that was taken, or one the reader cancelled, has said what it had to say by leaving.

The walk

Up, from the box, enters at the newest and walks older until the oldest queued prompt; one more down past the newest lands back in the box, focus included, so the next Enter is the next send. Escape is the same return. ⌫ cancels the walked row; the pile hands the keyboard back to the box when its last row leaves, so a keystroke is never dropped on the body.

The wire

The pile follows the CLI’s own lifecycle frames, measured on live captures:

  • a prompt written under a uuid answers command_lifecycle queued 1-5ms after the write - the card enters the pile;
  • started (the same command_lifecycle frame) lands where the CLI takes the prompt out of its queue: at the turn boundary that delivers it, or absorbed into the running turn (the removal’s reason absorbed_mid_turn). The card drains into the chat as the row it becomes, carrying its wait under it (queued 0:0n · sent). While it waited, the card was the only drawing of the words: the row is drawn at this frame, which for an absorbed prompt is inside the turn the press interrupted;
  • completed says the turn that carried it ended.

A cancel dispatches the client’s own cancel_queued_prompt, which the core carries to the CLI’s cancel_async_message control; the CLI answers {"cancelled":true}, and a cancel landing after started answers false - the card has usually drained by then.

A parked question is the pile’s other shape. Every prompt arriving while a question is up is queued and stays; the answer lands and all of them go started in the same instant, delivered in the request that carries the answer.

All of this carries only under forge’s uuid stamp - the CLI emits no lifecycle frames for an unstamped prompt (measured: 0 frames without a uuid, 6-9 with), which is why every send stamps one. The live side of this is the queued_prompt_lifecycle scenario in the wire harness.

The two widths

The pile is drawn and checked on the session page’s column, at 1600 and at 430, where its cards take the column’s width and the words truncate rather than wrap. Every card control takes a finger’s height under a coarse pointer, per the composer’s own rule.

Board

One project’s task board, opened as a takeover at /board/{org}/{project}: a slim bar, the rows waiting on the reader, the project’s cards in lanes by state, and the line that cuts a new row. The fleet stays the glance; this is the world. Nothing of any other project appears here. The drawing it is held against is web-board.html, beside this page.

It is reached from two doors: open board on the project’s row on the home, and the tasks strip in a session’s chat, which carries open board first in its list. Both open the same page. back and done return the reader where they came from, and a deep link with nothing to go back to lands on the home.

The page draws from the board subject: a project’s rows, schedules and connector sets ride in that subject’s entry, and it hears the updates that move them. The seat chips and the fleet’s waiting on you count come from the home snapshot beside it; the row and running counts are the board’s own. So a seat that claims a row, a row that is cut, or a move made in another view arrives as an update, and the page re-reads without the reader doing anything. While it is mounted it also re-reads on a slow 30 s beat: a row’s age, its worked time and the marks read off them move with the clock, and no update announces that.

What it draws

RegionShowsRead from
Top barback, the project’s name with its own read (N waiting on you, N rows, N running), donethe route and the snapshot
Seatsone chip per seat this project holds, linking to its session page; a card dropped on one assigns itagents filtered to the project
Waiting on youthe rows whose wait is a decision, each card leading with the wait’s ask: the verify gate (approve, a words box, send back) on a row minted verify: user, or a worker’s question (an answer box), with the row’s links and a look’s artifact beside it. A root whose children are not all done draws the count and the send-back instead of approve, because a row closes only when its children dowaiting_on with kind: decision (a verification park only where the row’s own verify is user), the wait’s detail, the row’s links, and the row’s rollup
Lanesevery card, in its state’s lane, with the grown groups folded - the finished ones to their five most recent, Backlog to its tenthe board’s projects[].rows
Createa subject box, an epic picker and + add, which files the row at the queue’s endtask_create

The lanes

Six lanes, always drawn in this order, because a missing lane would read as a missing state: Backlog (pending), Waiting, In progress, Completed, Failed, Canceled. The pool of everything not yet started leads, then the lane that wants the reader, then the work. Backlog is the pending status: work kept to look at later, not by itself an assignment and not a promise to start. Its ready, unowned cards are the dispatch queue, not a promise to run. A lane is a name, its count and a rule, with its cards under it - no box around the column - and the lanes wrap to a second row rather than scroll sideways.

The grown groups fold. A finished row accumulates for the life of the project and a queue accumulates for the life of the work, so Completed, Failed, Canceled and Backlog are the lanes that grow without bound. The three finished groups draw their five most recently finished rows - by when they last moved, whatever their place in the lane - and the Backlog draws its top ten, larger than the finished groups’ five because the queue’s order is the lead’s dispatch order and the head of it is what stays in view. The rest of each group waits behind a show all (N) control under the last card. N is the lane’s true count, which the head carries whether or not the group is open; pressing the control draws every row and it reads show less, and a group at or under its limit draws whole with no control. A card the reader is dropping into a folded group stays drawn while the core’s answer is in flight, and a jump aimed at a folded row - an epic pill, a row page’s task list - opens the fold and lands on it. In progress and Waiting never fold: they are the live work.

A card

Three lines, top to bottom:

  • the mark, then the subject. A row in progress displays its active_form where it has one, with the subject under it, because that is what the reader wants to know - subjects are what a row is, active forms are what it is doing. A completed row’s subject is struck through.
  • the owner (their initial in a disc, linking to their session page, or unclaimed), the epic it belongs under where it has one, its links, and its chips - with a done/total rollup beside them on a parent.
  • worked time against the estimate with the measure under it, and how long since the row last moved (wrote 12s ago, breathing while the row is running and recently wrote).

The tree reads in two marks. A parent’s card wears an epic pill and a solid left spine; each child wears the epic’s name in a pill with a return arrow and a dimmer spine, so a tree reads across lanes at a glance and the board still works without colour. The child’s card pill is a door: it brings the epic’s own card into view and flashes it. On the row’s page the same pill is a label, and the doors there are the tasks it lists - what a parent’s tasks are, and how to reach one, lives on that page.

The mark carries shape as well as colour, so the board reads without colour:

StatusMarkThe state
Pendinga ringqueued, not started
In progressa filled accent dota seat is on it
Waitinga warning squarea wait, a question or the verify gate
Completeda tickdone
Faileda crossit ended in failure
Canceleda dim barwithdrawn, kept for the record

The chips are the server’s derived marks, named in words:

ChipToneMeans
waiting on youwarna decision wait, or a look park on a row minted verify: user
on <row>warnwaiting on another row’s completion
waiting on a resourcewarnwaiting on something outside the board
waitingwarna verification wait whose row carries no own gate: an inherited park, left behind by the old rule
waiting <3h>badthe wait has outlived the threshold
overduebadworked time has crossed the estimate
no movement <5h>dimuntouched, and nothing is waiting
owner gonebadits owner is no longer a live seat
in reviewbluecompleted with the verify gate up
lead attestedbluethe lead completed the look, citing the user’s own approval
to closebluea root whose children are all done, waiting for its close
reserved for youdimthe user reserved this pick: the sweep leaves it, it never reads ready, and no claim takes it
attempt Ndimsent back with words N times

The look has two doors, and the row page draws both. The user’s own approve sits on the waiting strip; a lead who watched the user approve in chat completes the row through tasks__approve, which requires the approval quoted and records it on the row. The completed card then wears lead attested, and the row page draws the quote with a reopen button: reopening strikes the attestation, re-mints the look gate and parks the row on the user again. The page’s pick line is the other side of the sweep: reserve for you marks the row so the sweep never dispatches it, and release takes that back. A reserved row is not ready and no seat’s claim takes it: the pick is the user’s alone. The line draws only while the row is live - a completed, failed or canceled row has no pick left to reserve.

A wait leads with its ask. The waiting card and the row page both open on the wait’s own detail where it states one, else the row’s detail - a look parks with no wait detail, and its ask is the row’s own words - and a wait with neither falls back to the state’s sentence (“A decision is waiting on you”, “A look: this row is finished and waits on your approval”). That fallback is a seeded or legacy wait, or a look minted with no ask, and a blank detail counts as none. The row’s links follow the ask as references in the sheet’s own link grammar - the app’s link colour with an underline set off the text, and a fuller rule under the pointer; no bare anchor reaches the strip, so none draws the browser’s purple. In a look the artifact is its own door, look at <name>, wearing the same pill grammar as approve and send back rather than sitting in the sentence as another link; when the artifact is already one of the row’s references, the door is its only showing and the links line keeps the others, so one reference appears once. A good ask says what the question is, why it needs the user, and links the thing to look at; a bare “waiting on you” is a card with no question on it.

The links come off the row’s own list: a URL follows in a new tab under its label, and a path or a branch draws as text rather than a dead anchor.

Moving a card

The drag is the board’s interaction, and what the card lands on decides the command: another lane is a move carrying its place (task_move), a position inside its own lane is a re-rank by drop position (task_rank), and a seat chip is an assignment (task_assign). A lane’s whole column takes the drop, an empty one included, and the place the drop chose is the priority the row keeps on either path. A press that never passes six pixels is a click, which opens the row’s own page. A dropped card lands where it was put at once and the wire’s own snapshot reconciles it a moment later; it animates into place unless the reader has asked for reduced motion, and the card under the pointer tilts and lifts.

The keyboard has the same power without a row of buttons: the arrow keys on a focused card re-order it within its lane, and the owner picker on the card is a control like any other.

The row’s page

A click - or Enter or Space on a focused card - opens the row over the board: its fields (state, owner, estimate, worked, both clocks, its parent, its pick line and its links), Its tasks on a parent - each row a door that closes the page and lands on that card - Waiting on you when the wait is a decision, carrying the wait’s ask, its links and a look’s artifact, then the waiting strip’s own controls (approve, the send-back box, the answer box), while a dependency or resource wait draws the section without them, its History - the row’s own transitions, which the core already records for the worked time - and the way into the owner’s session. The read caps what a row carries at its newest twenty, so a long trail reaches the page without riding every board encode, and the section words what it withholds (showing the last 20 of 43). The button, the scrim and Escape all close it, and the card has the keyboard back when it goes; a jump hands it to the card jumped to instead. The board stays behind it, so a reader never loses the lane they were in.

Every control is one command on the socket, carrying this project on it. task_move moves a row between lanes and lands it at the drop’s place, task_rank re-orders it, task_assign changes its owner, task_verdict approves or sends back a verify row, task_answer answers a question, task_create files a row, task_reopen strikes a lead’s attestation and parks the row on the user again, and task_reserve reserves or releases the pick. A refused edit says so on the service line rather than silently reverting, and every one of them stamps the change as the user’s.

The states the page can be in

  • A board, the ordinary case.
  • Waiting on the reader: the strip draws only when at least one row’s wait is a decision. Its absence is not a state - an empty frame above the cards would read as a board with nothing for the reader, which is a different claim from the strip not being there.
  • Empty: a project with no live rows draws one line naming its two moves
    • cut a row in the create line, or tell the lead what is next - with the six lanes still drawn under it.
  • Before the first frame: a route can be addressed before the snapshot lands. The page arrives with the connection rather than drawing a second loading state of its own, and a subscription the server turns down says so in its own words instead of reading forever.
  • Unknown: a project the snapshot does not carry draws its name and one line saying this forge does not hold it, with no lanes and no create line - a row filed there would name a project forge.toml has no entry for.
  • A set of seats: the seat chips draw only when the project holds one; a project with nothing running draws none.

The two widths

Checked at 1600 and at 430, against the drawing beside it. The lanes are a grid of minmax(260px, 1fr) columns, so a wide window fits them side by side, a phone stacks them, and neither scrolls sideways. Every control a finger reaches takes a 44px target under @media (pointer: coarse), and the drag is a pointer gesture, so it is the same one under a finger.

Review

One seat’s reviews, addressed at /session/{org}/{project}/{label}/review: the loop’s state, the threads whose code has moved out from under them, the diff they are anchored to, and the writing - a comment on a line, a reply, a resolve, a round sealed. The drawing it is held against is web-review.html, beside this page.

The route is a sub-path of the seat because the seat is what a review is keyed to: the record’s review read is the seat’s own branch, so reviewing a worker’s work means opening that worker’s seat. The page is reached by its URL, by the back link in its header, and by the strip’s review row on the seat’s own page.

The two halves

Conversation draws the review as it ran: the parked threads, then the rounds newest first.

Files changed draws the review as it is anchored: the scope control, the file tree, and the diff with the threads on their lines.

RegionShowsRead from
Headerthe back link to the seat, Review, the branch, the round count, and the loop’s state - N replies to read, in the warn tone while any thread waits on the reader, and N openthe record’s work.branch, and the two review halves
TabsConversation and Files changed, with the commit countthe record’s git chain
Scopethe scope on screen and its place (scope 3 of 9), a stepper whose ends stop, the keys, the unified/split picker, and the comments’ stepper with its N/M countthe record’s git chain (the commit list), and the scope’s own threads
Basethe ref the All changes diff is measured against: the page asks for its own read on open, and that read answers at once - the last completed check the checkout holds, or the base unchecked when it holds none - and kicks a throttled background check whose measurement turns the page verified when it reaches the page; a line above the canvas says how far behind upstream it is, or that it could not be checked together with the fetch’s own words for why, when the fetch could not landthe record’s diff.base and diff.baseError, and the ref’s name from record.git.defaultBranch; the page’s own re-asks watch record.git.base
Treethe scope’s files as their own hierarchy - a row per directory and per file, folders before files, indented by depth, each row carrying the files view’s own glyph for its kind; a directory carries the files beneath it, and the file whose region the reader is inside is marked as the current onethe scope’s diff, the threads in that scope, and the canvas’s own scroll
Canvasthe scope’s files as diffs, with each thread inserted under its own line, the composer under the line a comment is being written on, and GitHub’s own gap strips between two hunks (above the first, below the last) that draw the lines the diff left out, a press at a timethe scope’s diff, the threads, the server’s landing per thread, and the file’s whole content read
Outdatedevery thread whose code could not be placed, drawn against the lines captured when the comment was writtenthe threads half
Roundsnewest first: the round’s number, its age, its tally by state, its summary, and each member threadthe rounds half
Finishthe round’s summary - a roomy, resizable box - over finish review (N), the mic before it and the pair flush right so the button keeps the corner the collapsed control has, live once a comment is drafted; the conversation half’s own section, and, on the diff half, a control under the canvas that opens the same stack in place; a seal returns the reader to the seat’s chatthe page’s own drafts
The chat linethe one-line nudge the view this client replaced sent, pushed into the reviewed seat’s own chat: Review #N ready - scope <branch>. Read it back under that scope, or hand the scope to your lead if you do not have the review tools when a round seals, Reopened a comment in review #N - scope <branch>. Take another look under that scope, or hand the scope to your lead if you do not have the review tools on a reopenthe page’s own dispatch after the write

A scope, and where a thread draws

A scope is All changes or one commit. A thread draws in the scope it was written in, and in all changes whenever the content that scope records still carries its line:

  • A commit-scoped thread sits at its recorded line in its own commit’s view. A commit’s diff is immutable, so that line is settled by construction - there is nothing to re-resolve. In the all-changes view it draws where the server’s landing puts it: the record resolves a commit-scoped anchor against the branch’s content too, by content and neighbours alone (its recorded number counts in the commit’s numbering, so it is never the evidence), and one the branch no longer carries parks as outdated there - its own commit’s view still draws it where it was written.
  • An all-changes thread sits where the server’s landing puts it. The record carries, per thread, the resolver’s own answer against the content it ships - placed at a line (with the line it moved from, when it moved), or outdated with the resolver’s reason. A thread with no landing carried - a read that answered none - is parked rather than guessed onto its recorded line.
  • A commit’s view draws only its own threads. An all-changes thread’s numbering is the branch’s and the record carries no landing for it against a commit’s diff, so placing it there would be a guess the resolver refuses to make.

The all-changes diff is measured against a base the page’s own read answers at once, and the check that keeps it fresh runs off the read. Opening the review page asks the server for a review-scoped read. On a seat with a session, the base fetch is never waited on: it answers the last completed check the checkout holds, or the base unchecked when it holds none, pinned to the local tip the diff is read at, and it kicks a throttled background refresh either way. A seat with no session is the one exception: no watch runs for it, so nothing would deliver a kicked check to the page, and its read is the one that runs the refresh and waits for it. A local base runs no fetch at all: the read probes whether it can lag behind anything and draws local when it cannot. A base the remote has moved past moves the merge-base back and folds main’s own commits into the branch’s diff, so the read never draws one as the branch’s own change without saying what it holds. A checked base may be up to four minutes old - the store’s own window - and the page draws its state without its age. While the record is being read the page says Reading the base for the diff... with the work spinner; the check itself is no longer part of that wait.

A read with a watch behind it never joins a refresh already in flight. When a check lands, the seat’s row carries the measurement at its next watch pass and arrives as work_changed; the page sees the measured base - its state or the words a failed check left - disagree with the one its own read drew and asks its own read again, which answers from that measurement rather than paying the network. So the page turns verified on its own with no second fetch and no second click. Every other read of a seat with a session serves what the stores hold and never waits on the base (a seat with none still looks its PR up on the read - see the socket page - which is that read’s own network call), so a record this page did not read itself is drawn with no base line at all: it has not checked, and the page says nothing it cannot back. When the fetch cannot run, the page says so above the canvas instead of drawing the files as the branch’s change: plainly that the base could not be checked, and, once the failed check lands, the fetch’s own words for why. A lag count is still drawn when a record carries one, and this server no longer measures it: a successful refresh trusts the ref its own explicit refspec wrote, and a failed one reads no remote tip to count against. A commit’s own view reads that commit’s immutable tree, so it carries no such line.

The keys, as the keycap row under the tabs states them: ← and → step one scope and shift+←/→ step one comment. The scopes are a line, not a ring (Ved, 2026-10-22): all changes sits at position 0 and the commits 1..N follow it oldest to newest, so right from all changes lands on commit 1, right at N stops, and left walks back to all changes and stops there - each end’s arrow disabled rather than wrapping, so which commit is first is never ambiguous. The comments’ walk still cycles. a flips between all changes and the commit you were on, and j opens the picker (arrows and Enter; Escape closes). The brackets do the same stepping as the arrows and are not advertised beside them. The comments are walked in the order the canvas draws them, by path and then by line, and each step brings its card into view. Both walks have buttons in the tabs row - the scope’s stepper and, beside it, the comments’ stepper with its N/M count - because a key alone is no door on a touch screen, and the scope’s arrows disable at the two ends. The keys are live only while the diff half is on screen, and a keypress in a text field is the field’s.

The tree has a walk of its own, the files page’s own rule: ↑ and ↓ move the mark without a prior focus, moving onto a file scrolls to it, Home/End go to the ends, and Enter opens a file or folds a directory

  • the picker takes the arrows while it is open. Only the vertical pair: the horizontal arrows are the scope and comment stepper’s here, so folding stays a press on this page.

The commit list is the record’s own, drawn oldest first, each row carrying its subject, its short sha and how long ago it was committed - worded as the board words it, with the review reading days from two days old (26h ago under that, 2d ago past it). The walk carries at most twenty commits: a longer chain numbers each carried row by its true ordinal - the oldest carried one is not commit 1 - and the picker says how many oldest ones it did not carry; a chain whose list could not be read says that instead of drawing an empty list. A commit’s diff is fetched when its scope is first shown, and not carried on the record - a branch can hold dozens of commits, and every reader of a record would pay their content on every read for a scope only this page draws. An ask the socket did not take is asked again, so a scope opened while the connection was down recovers rather than reading for ever.

The tree draws the scope as its own structure, not a flat list of paths: one row per directory and per file, folders before files at every level, indented by depth, and a directory’s row carries every file beneath it - which is what its collapsed row hides. A file states a side of its change only where it has one, so a file with no removals carries no -0, and each file row carries its full path as its hover title.

The tree also says where the reader is. The file whose region holds the canvas’s reading edge - its own top at the wide width, the window’s where the page is the scroller - is marked as current, and the mark moves as the scroll moves, one mark a frame. A reader three files into a long branch does not have to hunt for their place in the list.

The canvas draws what is on screen. The sections are virtualised over files - the browser holds the two or three a reader can see and the rest stays data - which is what makes the unified/split switch fluid: the mode change re-renders the tables on screen, not every table on the branch (measured at about ninety milliseconds after the change, against three-quarters of a second for the whole branch). The tree’s rows jump to a file the list has not drawn yet.

A diff’s hunks leave lines out, and the page can draw them. The record carries bounded hunks, so the lines between them - and above the first, below the last - are exactly what it does not carry. The controls are GitHub’s own, down to the glyph and the placement: a strip in the hunk header’s line-number column, on the row the diff already draws, with the gap below the last hunk getting a row of its own under the file’s last line; hovering lights the strip. A gap with no more than twenty lines left opens in one press and takes the two-way unfold mark with GitHub’s Expand All; a longer one takes a strip per end it can be opened from - the fold-down mark above the fold-up one between two hunks, and a single direction at the file’s edges - each press drawing twenty lines from the end it names. On a press the hidden lines draw as context around the hunk they sit with: the diff text is re-emitted with the heading grown by them, because the viewer numbers every row from that heading and rows spliced in by hand would draw a numbering it never agreed to, and a gap opened all the way joins the two hunks into one block with the second heading gone, the way GitHub closes one. The lines come from the file’s whole content read at the scope’s own revision - a commit’s tree for a commit’s scope, the branch tip for all changes - bounded by the same ceiling a document read is, and answered as a flagged short read at that ceiling rather than as a refusal, its lines capped at ten thousand besides. The page asks for that read when it draws a file that can have a gap - not a deleted file, a binary, a submodule, an added one whose single hunk is its whole content, or one whose hunks the record itself cut, none of which a control can ever fill - because the answer decides which controls exist: a read that could not run, or one cut before the gap it would fill, earns none rather than a door that opens on nothing.

A revealed line takes a comment like any other (Ved, 2026-10-22): it carries a door on its own side, and its anchor’s text and neighbours come from the file read rather than a hunk. The record places such an anchor in the whole file - the branch tip for a new-side line, the merge-base for an old-side one - because the line is outside the bounded hunks by construction; a line the hunks do carry is placed there, and text the file no longer carries still parks as outdated. The record reads at most eight such files per encode: past that an anchor outside its hunks is answered by the bounded content, which does not carry its line, so it parks rather than costing an unbounded pile of git reads on every seat encode. A commit-scoped comment is the one exception: a line the hunks carry places, but a revealed one counts in that commit’s numbering, so the all-changes view parks it - its own commit’s view still draws it at its recorded line. A line no press has revealed, or one the read did not carry, takes no door. A placed comment reopens the gap holding it when the page loads, from the end its synthesis draws - a gap above the first hunk opens from its bottom, one below the last from its top, and only a gap between two hunks takes the nearer end - so a reload does not leave the card with no row to draw on. A placed comment the file read cannot reach

  • a read in flight, failed, or cut before its line - draws as a parked line in the file’s own section, naming its state, its line, and that its content is not in hand, so the tally and the comment stepper never count a comment that is nowhere.

A thread

The state chip, then the anchored path and line, then the turns: the reviewer’s read as you behind an accent rule, an agent’s by its label behind a plain one. A turn carries one meta chip beside its name: the round that sealed it, which only a reviewer’s turn has, or else its age from the second turn on, because the thread’s opening turn has nothing to be later than. Both halves of the page draw the same turn through the same piece, so a chip is a chip wherever the turn is read.

A turn’s words render as markdown, through the chat’s own renderer: a reply carrying backticks, #[async_trait] or a fenced block reads here the way it reads in the conversation, and lines typed into a comment stay lines.

The four states are the server’s own: open, answered by nobody yet; addressed, the agent has answered and the ball is with the reader; resolved, read and accepted; outdated, the anchored code moved too far to place. A resolved thread collapses to its marker line - on the canvas, in the conversation and in the files reader alike (Ved, 2026-10-17: “git diff also does not show the resolve expansion like the file view does”): a press opens it again, where its reopen and collapse acts are. The set of threads the Outdated section lists is the page’s own reading of the record: a thread whose landing says outdated parks, and so does one whose read carried no landing at all - whether the store’s status agrees is not the test, because a store status was written by whichever view last resolved and cannot say where the code stands now. A thread with no rounds filed yet draws nothing in the conversation; it is a draft until a round seals it.

A thread is drawn where its own side is. An old-side thread - a comment on a removed line - draws under the removed line’s row, and in split view the card holds the half it was written on rather than spanning the lane; in unified it takes the whole width, as the line itself does. The same rule runs the doors and the drawing: one door per side’s number cell in split, one per row in unified (an added or context line is the new side, a removed line the old), and a click anywhere on a line opens the composer on that line’s side. The split’s halves are the table’s own: a removed line’s blank half is a placeholder cell whose class a naive side test reads as old - “placeholder” contains it - so a card written there would count the whole lane as its own side and span it. The colgroup names the columns, and the card takes the half the comment was written on.

The loop’s N replies to read is derived rather than served: it counts the threads in addressed or outdated whose last turn is an agent’s. An open thread never counts, even with an agent turn last, because that state is only reached by a reopen or by a thread’s code returning - both the reader handing the work back - and an author this build cannot name is drawn without being counted, so the tally agrees with every other surface’s.

A worker’s answer reaches this page through the record: the reviews ride the snapshot, so a re-read is what carries it, and nothing pushes a review change. The terminal’s activity line - the worker addressed review #N row it drops into the chat - is that view’s own and is not drawn here; the frame also carries nothing this page could not read from the record itself.

Writing

The page writes through the core’s review commands: a comment is an upsert_review_thread carrying the thread, a reply the same command with the reviewer’s turn appended, a resolve or reopen a set_review_thread_status, and finish review a submit_review sealing every thread this sitting has drafted or replied on. The summary field is the round’s own.

A comment is editable and retractable wherever its card is drawn (Ved, 2026-10-17): edit on a turn the reader wrote opens the page’s own box under it with its words - the same take and mic every box wears - and save upserts the text back with the turn’s identity kept; delete takes the thread away through a delete_review_thread, asking first because a delete cannot be undone. The canvas’s cards and the conversation’s carry both.

A comment opens with the caret already in it. Clicking a line’s door - or anywhere on the line - is a reader asking to write there, so the box’s field takes the focus as the row mounts; a click on the line the box already sits on puts the caret back in it.

The diff half can finish the round where the reader is. The row under the canvas carries a finish review (N) control at its right while the round is open; a press opens the page’s own finish stack in place - the same summary box, the same seal, one snippet drawn by both halves, the shape every surface that finishes a round draws (the files rail, the document’s exit) - the field takes the caret, and Enter (or the button) seals the round. The button keeps the row’s right edge whether the stack is open or collapsed, so the second press lands where the first did. A seal closes the page onto the seat’s chat (Ved, 2026-10-28), the way a hand-off’s Done does: the round is filed and the agent is who the reader came from. The write path is the conversation half’s own either way.

The finish nudges the seat the review belongs to. A round that sealed also sends a prompt_under into the reviewed seat’s own chat - one line, never the comments: Review #N ready - scope <branch>. Read it back under that scope, or hand the scope to your lead if you do not have the review tools, the round’s own number and the scope in it, the one-line nudge the view this client replaced sent on its own seal. The worker therefore hears a review is ready and reads it back under that scope, or hands the scope to its lead when it has no review tools. Only a round with work in it is worth the nudge: a round whose sealed threads are all resolved or outdated files without one, the predicate the view this client replaced sealed by, and a reply that carried no round nudges nobody - a worker sent to read a review that does not exist is worse off than one told nothing. A nudge is best-effort besides: one that could not leave is the worker’s own line missing, never a lost review, so its failure is drawn nowhere.

A reopen nudges the same seat the same way. Flipping a thread back to open hands the work back to the worker, so the line names the round the reopen is about - the thread’s latest filed one, never its origin: Reopened a comment in review #N - scope <branch>. Take another look under that scope, or hand the scope to your lead if you do not have the review tools, or the round-less Reopened a review comment - scope <branch>. Take another look under that scope, or hand the scope to your lead if you do not have the review tools for a thread no round has filed. A resolve does not nudge at all: a thread read and accepted needs no second look.

The anchor is captured where the lines are. The comment is written on a line of the drawn diff, so the page takes that line’s text, its neighbours - within the hunk, or within the file read for a revealed line - and the crate’s own hash of the line, the capture the crate’s resolver defines, and the thread carries it. The hash is FNV-1a over whitespace-collapsed UTF-8, and the vectors the client’s implementation is pinned against live in the crate’s resolver tests too: a hash the two disagree on would leave a thread written here unplaceable when the core resolves it.

The hash crosses as decimal digits rather than a JSON number. JS numbers are doubles, exact only to 2^53, and the hash is a full u64 - so the anchor’s serialization is the digits, and the deserializer accepts a number too, which is what blobs written before this held. A thread read back is echoed verbatim on a reply, so a round-trip cannot round the hash off.

Every write re-reads the record when it lands, because nothing pushes a review change: the drawn round, the drawn thread and the header are always the store’s own answer rather than the page’s guess at what the write did.

Every box on the page dictates, the way the composer’s box does. The comment box, the reply box in either half and the round’s summary field each wear the composer’s own mic control - a press starts a take, a hold opens what dictation is set to - over the page’s own take, so a take begun here is addressed to the seat this review belongs to and the server sees an ordinary take on that seat. The summary’s own row is drawn by whichever half is up, and it goes with that half: a take begun in the diff half’s finish stack ends when the row does.

The visible take bar takes the line of the box that began the take, exactly as the composer’s bar takes the draft’s own line: the row the words would be typed into is the row they arrive on, so nothing is added and nothing moves as a take starts or ends, and no other box draws a bar for a take that is not its own. The words land at the end of the box that began the take, if it is still open when the take settles - a box the reader closed has nowhere to put them, and the words are dropped rather than written where the reader is not. A notice about a take this page never watched (a page that attached or reloaded on the previous take’s words) is not placed at all: a carried notice is the record re-handing words to a box this page no longer holds, so it is dropped rather than guessed into another box. A take that could not start says so in the box it was pressed in - the line belongs to the attempt, not to the next line the reader opens.

The push-to-talk key is one registration for the page, because the microphone is the client’s own and every box drives the same take: a registration per box would fire the key once per open box. It begins its take in the box the reader is in, else the one open, else the summary - the same press and release the composer’s box reads - and it is offered only where a take has somewhere to land, since a take whose words no box can take is a take the reader loses. Every box on the page is covered: the comment box on a diff line, the reply box under a thread, and the round’s summary, wherever it is drawn.

The write’s outcome is read. The two commands that answer through a reply carry the server’s own answer back - the store’s yes, or the word a refusal came as - and a dead socket throws before anything goes; either way the draft is added only once the write landed, the box that wrote it keeps its words, and the refusal is drawn where the reader acted - at the top of the page at the wide widths, and riding the top of the viewport at the narrow one, where the single column can carry the reader far past it. finish review keeps the summary and the drafts on a refusal rather than clearing words the round never filed, and reads the minted round’s number out of the reply’s own body - that number is what the nudge carries.

The states the page can be in

  • A seat’s reviews, the ordinary case.
  • Nothing yet: Reading the base for the diff... with the work spinner before the first record arrives. The page’s own read builds the record before it answers, and on a seat with no session it also carries the base check; the line names that work rather than drawing a generic load, and a branch with nothing to fetch still reads its base, so the words stay true either way.
  • A refused seat: the server’s own sentence, drawn the way the sibling routes draw a refused subscription.
  • A half that failed: the two reads fail apart on the server, so each is drawn apart here - a failed threads read leaves the rounds drawn, and the reverse - and a half whose outcome this build cannot name draws that word rather than being folded into a failure it is not.
  • Empty: both halves read and nothing filed draws Nothing has been reviewed on this branch yet.; threads drafted with no round filed says how many are drafted.
  • A scope with nothing in it: All changes on a branch with no commits of its own says so; a commit that changed nothing says so; a scope whose read failed says that; and an unnamed layer state draws its own word.
  • A commit being fetched: Reading the commit... until its answer lands.
  • A file’s content being read: no between-hunk control is drawn until the read lands, and a read that failed, refused or was cut before a gap draws none for it - the controls are only ever doors onto lines the read actually carries.
  • A take running: the take’s bar in the line of the box that began it - the composer’s own reading, ready, recording and transcribing - and the mic beside it says stop dictating; the words land in that box when the take settles.

The strip’s row

The seat’s own page carries the door: the strip above its composer draws a review row wherever the tree row draws - a branch of its own, a dirty tree, a pull request, a tree that could not be read - labelled N to read while any thread waits on the reviewer, the thread count otherwise, and review when nothing is filed. Its hover panel lists open review, then each thread by its file and line with its state and the turn a reader would be reading; a branch with nothing filed draws the row with no thread lines, and a threads read that did not land draws as itself, one row naming the failure. Neither row draws on a clean default branch, which has no tree state to state.

The two widths

Checked at 1600 and at 430. The header wraps rather than scrolling sideways; the tree and the canvas stack below 980px and the page scrolls as one column.

Where the surface differs from web-session.html

The session page’s drawing named a diff review overlay as its sixth section, deliberately not built at the time. The surface landed as a route of its own instead, so a review is addressable and a second reader can sit on it while the session page draws the conversation.

The files

One seat’s whole working tree, readable and commentable: the tree, a read-only reader over each file, and the comments dropped on what was read, listed beside the reader. It is reached by its URL, by the back link in its header, and by the strip’s files slip on the seat’s own page - one place, and the tree follows the seat. The drawing it is held against is web-files.html, beside this page.

Two halves, like the review page’s: the Files tab is the tree, the reader and the comments rail - which carries the dropped list and the finish and is that tab’s alone - and the Conversation tab is the seat-tree scope’s rounds, newest first, taking the whole row: the same conversation the review page draws of a branch, through one component both pages use.

It lives at /session/{org}/{project}/{label}/files. The tree is the seat’s own: a worker’s worktree when the seat is a worker, the lead’s checkout on the lead, which is the directory the record already reports as the seat’s scanning cwd. Nothing here reads the launch directory, and the tree needs no new walk: the seat’s file index - what the composer’s @ list already reads - is the tree.

Where a comment is filed

A comment dropped here is filed under a seat-tree scope, spelled tree:{org}/{project}/{label} - a third scope beside the branch scopes and the document scopes the rest of the review plumbing uses. The scope is the seat and never the branch, so a worker that commits, or rebases, or moves its branch keeps its comments; a comment threads, replies, resolves and seals into rounds exactly as one on a diff does, and the worker addresses it through the same review MCP when it has the family. When the reader seals a round, finish review files a submit_review and pushes Review #N ready - scope tree:{org}/{project}/{label}. Read it back under that scope, or hand the scope to your lead if you do not have the review tools into the seat’s chat, the review page’s own line.

What it draws

RegionShowsRead from
Headerthe back link to the seat, Files, the seat’s resolved tree and its file count, N to read while any thread waits on the reader, the Conversation / Files tabs, and the narrow doorsthe record’s state.scan_cwd, its file_index, and the seat-tree threads
Conversationthe rounds newest first, each with its age, tally chips, summary and threads - state chip, path:line, the turns with edit, and respond / resolve / reopen / delete (an accepted thread collapses to its marker, opened by a press, where only reopen, collapse and delete sit); parked threads above in an Outdated group with their captured linesthe record’s tree_reviews; the page’s own drafts and reply box
Treethe seat’s whole index as its own Project pane: the project row carrying the project’s name with the seat’s path beside it, then a row per directory and per file, folders before files at every level, indented by depth, each row wearing the 16px glyph the material icon theme gives its filename, extension or folder name (folder tints included). A folder holding a file starts folded; a folder holding only folders starts open, so the first view is the tree’s shape with no file rows inside folders (root files still draw). A filter stands every fold down so its matches show, and a jump from the comments rail clears the filter and opens the folders above its file. With the current file marked, a pill per file carrying its comment count, and a filterthe record’s file_index and state.scan_cwd; the filter ranks by the @ list’s own matching; the glyphs come from material-icon-theme’s manifest
Readerthe file’s head - its path, and what the per-format policy says it is: a grammar and line count, plain text - no syntax for this file, a mime and its dimensions, or a mime and size - then the file itself: a commentable editor for markdown, code and text, a bounded picture, a PDF’s pages, or a notice naming a type no viewer draws; the target line carries the tint and the gutter +the document_read answer for the open path: its bytes
A threadunder the line it was written on: its state chip, where it is, its turns through the review page’s own turn renderer, edit on a turn the reader wrote, and respond / resolve / reopen / delete; a resolved one collapses to its marker line, opened by a pressthe record’s tree_reviews threads and their landings, and the page’s own drafts
A comment boxunder the line a press landed on, the caret already in it; Enter comments, Shift+Enter keeps the newlinethe page’s own comment shelf
Commentsevery thread in the scope as one list, by path and then by line: the state chip, path:line, the first words, and delete; a row jumps back to its linethe record’s tree_reviews threads
Parkeda thread whose landing could not place it - the code moved too far, or no landing was carried - says so on its row, its captured lines kept; it is never droppedthe landing’s outdated word, or none carried
Finishthe round’s summary - a roomy, resizable box, present by default in the rail’s bottom, its own mic like every box - over finish review (N), which seals the sitting into one round; a refusal keeps the wordssubmit_review’s reply, and the round’s number
Statesreading; a seat the server refused, in its own words; a tree with no files; no match for the filter; a file with no viewer, in a notice naming its type and size; a file read refused with its own sentence; a seat-tree review read that failed or answered a shape this build cannot name, drawn in its own line rather than folded to the clean empty pagethe record’s and the read’s own outcomes

Reading

The read is the document read - the server carries the file’s bytes, the client draws them - so it resolves against the seat’s tree, refuses with its own sentence (missing, unreadable, over the ceiling, outside the tree) and answers the file’s bytes. A file that does not decode is a binary read rather than a refusal: the page draws its notice. The reader shows the bytes from when the file was opened and re-reads on a re-select: an open file is a snapshot, never rewritten under the reader’s eyes while a worker edits it.

What a file is comes from one policy, shared with the chat’s Read rows (client/src/format/policy.ts): the extension, then a filename (Justfile is just, Makefile is make, Dockerfile is docker, a .env file is dotenv), then a shebang, then whether the bytes decode as UTF-8. Markdown, code and text draw in a read-only CodeMirror instance, coloured by the same shiki highlighter the chat’s code panels use, in the github-dark family (Oniguruma; the wasm loads when a code file is first opened, and that open waits on it rather than colouring in after a plain draw). Each language has two layers, neither guessed: structure and folding from the CodeMirror org’s own package where one exists, and the colouring from shiki’s grammar either way; .svelte and .toml are colour-only. A text file no grammar names draws plain, and the head says plain text - no syntax for this file. Grammars load one file at a time from shiki’s lazy set, so the reader carries the whole catalogue without downloading it up front, and the chunk - CodeMirror, the bridge - is pulled only when the reader first draws a text file. A picture draws itself, bounded, with its mime and dimensions; a PDF draws its pages; anything else draws a notice naming its type and size, and saying it is still in the tree and reviewable as a file. A reader that could not be built - a chunk or a grammar failed - draws that reason rather than an empty editor.

The reader draws by one policy; the document hand-off does not, by design. The read answers the hand-off’s extension kind and its commentable set; it does not widen. The reader’s UTF-8 drawing classification is the client policy above. So an SVG is a picture here and a .svg document in a hand-off, and an extensionless Justfile opens as just here and read-only in a hand-off - the hand-off’s set is what a reader may comment on, not what this page can draw. A file whose commentable extension is not UTF-8 is refused with the read’s own sentence; bytes outside that set are a binary read.

A press on any line is a comment: the line takes the tint and the gutter +, and the box opens under it with the caret in. Doors are press targets at every pointer, and the reader’s own scroll is capped by the sheet’s rule that nothing scrolls sideways.

The thread, and the round

An anchor captures what the review page’s anchors capture - the file’s path, the line, the line’s whitespace-collapsed hash, and its neighbours as context - from the same lines the reader drew. The landing is resolved by the server, because the client has no working tree to re-scan: the record carries, per thread, either the line it sits on now (with the line it moved from, when it moved) or the resolver’s own reason it could not be placed. A thread whose file has vanished, or whose seat has no resolvable cwd, is carried with no landing and parks.

respond appends the reviewer’s turn and flips the thread toward the worker; resolve marks it read; reopen returns it and nudges the seat. Every write re-reads the record, because a review change is not pushed - the reviews ride the record, and a record re-read is what carries them. The Conversation tab answers over the same calls a diff review does - reply, resolve, reopen - and a rail row opens its file at its line in the reader.

A comment is editable and retractable wherever its card is drawn (Ved, 2026-10-17): edit on a turn the reader wrote opens the page’s own box over that turn’s words - the same mic and take as every box - and save upserts the text with the turn’s identity kept; delete removes the whole thread, and it asks before it writes, because a delete cannot be undone. Both write through the review plumbing every other change rides.

The two widths

At 1600 the page is three columns: the tree, the reader, the comments. At 430 the reader is the page and the other two are sheets behind two buttons in the header - tree and comments (N) - each pushing a history entry so the hardware Back closes what it opened, with done and Escape as the other doors.

The slip

The seat’s own page carries a files slip in the strip above the composer, before the tree row: a bare door, the files mark and the label naming the tree it opens - walk · files for a seat in a worktree of its own, plain files for the project’s checkout. A press opens the page, and there is no count and no panel: the slip IS the door. It is the one mandatory segment - every seat has a tree - where the tree row beside it keeps its rule of staying away when nothing has moved.

Models

The page that shows which local models forge runs, what the runtime’s own catalogue publishes, which models this machine has downloaded, and how to bring a candidate onto the machine and make it the one a role runs. It is drawn from one snapshot of the dictate_models subject - the models in use, the catalogue check, the proposals, every row of the feed, the installed set, the download/activation state, and the speaking role’s own variants and voices - and redrawn from the update stream after it. The drawing it is held against is web-models.html, beside this page.

Three roles, one page. The transcribing and cleanup roles are the dictation engine’s; the speaking role is speech out, Kokoro-82M and the style files for its named voices. Every role’s row is drawn the same way - the spec’s facts, the feed’s measurement when it has one, the state chip, where the choice came from. The speaking model is installed from the Hugging Face Hub rather than the speech feed, so its role view carries its own install and switch controls, and [tts] speaking_model pins it. The name is the page’s, not a crate’s: forge-dictate and forge-tts stay what they are.

It lives at /models, which is its own address rather than a session’s: the models belong to the forge, not to a seat, and the subject carries no slot. The header’s way back is the home.

The catalogue has no other view: this page is the one that draws it. The read and the actions are the server’s (Subject::DictateModels, Command::DictateCatalogueCheck, Command::DictateInstall, Command::DictateActivate, Command::DictateDeactivate, Command::DictateUninstall, Command::DictateBench, Command::DictateBenchStop, Command::DictateBenchDelete, Command::VoiceBenchStart, Command::VoiceBenchStop, Command::DictateReadAloudStart, Command::DictateReadAloudStop and Command::DictateReadAloudDelete), and this page is the view of either.

What it draws

RegionShowsRead from
Headerthe brand mark, forge, the page’s name, and the way back to the homeClientSettings.mark from the greeting; the route
The page’s statea refused action in the core’s own words, and whichever download or activation is in flight with its progressrefusal (an error frame); install, activate
In useone row per model forge runs: its role - the selector for the updates below - its file, the facts the spec declares (size, quant, parameters, digest, licence), the feed’s own measurement when it has one, the live state as a chip, and a line saying where the model came fromin_use
Updateswhat a check reads, the check’s own line - up to date, an update, checking, unreachable, or a state this client cannot read - with both dictation roles’ news under it, then the selected role’s recommendation with the one control that takes it, then the comparison table behind a door; the cleanup role’s own view instead, where no feed measures a normalizer: its candidates with each one’s own runs under it; and the speaking role’s own view, which lists the Hub’s ONNX variants with their install and switch controls and counts the voices the model in use carriescheck, updates, rows, installed, results, speaking
Find a modela box that filters the feed’s rows as it is typed, with the feed’s families and its fastest rows offered before a name is known: the variant, the quant a machine would run, the measured speed and error, the licence, what it transcribes, and the row’s control. Each row links to the entry’s own documentrows
Benchmarkthe fixtures first - the read-aloud set’s own state with the record control and the count line - then the one press that scores the picks on this machine and leaves a verdict, then the speaking loop’s own card, then two doors: every run scored here, and every model a bench can run, with the control that removes a downloaded model nothing runsbench, read_aloud, results, speaking_bench, speaking_results

A model in use is drawn from its spec. The first fact line comes off the ModelSpec - the size, quant, parameters, digest and licence it declares - and never off the feed, so a model the feed does not carry still draws whole. The second line is the feed’s: its runtime measurement and its languages. A model with no join draws the runtime its spec declares beside not in the feed rather than borrowing a measurement - the speaking row’s runtime is onnx. The third line says where the model came from: compiled default, installed here, or pinned by [dictate] <key> - naming the key, because that key is what has to go for the runtime to move the role. The speaking row names [tts] speaking_model when that key pins it; the key accepts the model’s own name or one of its ONNX variants, and an unknown name refuses the boot the way an unknown [dictate] key does.

The check’s line is the feed’s freshness and its proposal at once. A fresh check with nothing to propose reads up to date; the same check with a proposal reads update available, because they are one state of one thing. The line states when the check ran - as a local time, from the server’s RFC 3339 stamp - and the release the feed stood at when it answered. An unreachable check carries the server’s own error text, and the rows the last fetch left stand. Under it the line names both dictation roles, without a press: the feed’s own proposal for transcribing, and the bench’s pick for cleanup - the thing that actually decides that role - so a reader learns there is news before pressing Check now.

A row’s control is the rule the core enforces, drawn. A variant this machine does not have offers install <quant>; the press fetches the variant’s doc, takes the quant the row draws, and downloads it. An installed variant that is not the active transcribing model offers use for transcribing; the press loads it while the current model keeps running - the core builds the new engine first, swaps, and only then drops the old one. The active model draws as a state (active) rather than a control, and an installed model under a config pin draws installed with no activation control at all: forge.toml wins over every runtime pick, so the core refuses that dispatch, and a control that is always refused reads as broken. The download stays on a pinned role - pulling candidates down is still allowed.

An update line carries its own control, which is the recommendation made pressable: the candidate’s comparison against the model in use, then the install or activation the rule above would draw - download when the variant is not here, then load it, one press, with the completion line when the role runs it. The bench that scores a candidate on this machine’s own recordings is the section below, not a second control in the line.

A line that says there is an update shows the update. The check line carries both dictation roles’ news, and under it the selected role’s recommendation is its own row: the model, its numbers against the one running, its licence and size, the rule it was picked on in words - it beats the model in use on both of the feed’s own measurements - and what taking it does: downloads it if it is not here, then loads it as that role’s model. Its licence is a fact on the row rather than a filter: what runs here is this machine’s own. The one control that takes it is the row’s own - download then load, or the download alone where forge.toml pins the role. A role with nothing proposed says so rather than leaving a blank. The table below it is evidence, not the pick, so it folds.

The comparison table is the rule’s own working, one row per model and one column per axis. Its columns are fixed widths, so the geometry holds still as the state changes under it - the in-use row’s file name, the pick’s control - rather than the table re-laying itself out while it is being read. The baseline row is the model in use: it draws its licence like every row does, and the words saying it is what runs sit in the rule column. Every candidate is read against that baseline - speed and error as vs pairs, the licence as the feed spells it, and the rule’s verdict: recommended, also beats both, but slower, slower than this, no more accurate, or a verdict this client is older than. The pick’s control is drawn beside its verdict rather than in a last column of its own, so a row’s action is labelled by the cell it sits in.

The role row is the selector. Pressing a role in use draws that role’s own proposal below - the section follows the first role that has one until a press, so a role with news is never hidden behind one without. A role the feed has nothing for says so in words rather than leaving another role’s table standing under its name, and a proposal whose candidate list is empty says that too. The whole row is the selector: a card that only answered on one word would read as furniture, so the press lands anywhere on it and the hit target is an overlay rather than the card itself - the row carries its own controls, and a button inside a button is not HTML.

The cleanup role has no feed to rank it, so the bench decides it. No feed publishes speed or error for a normalizer, so selecting the cleanup role draws its own view instead of a comparison table: the candidates the cleanup feed listed, each a link to its entry’s own document with the quant and the Hub’s own download count, and under each one the runs this machine has measured on it - the tier, the numbers, and what the run says against the cleanup model in use on the same corpus. The pick is the best of the runs that share one corpus, and only runs carrying an accuracy figure count: the takes tier reads agreement between two models’ words, where a faster normalizer is not a better one, so it publishes no word error and the pick waits for a corpus with words known to be true. Two runs on one corpus are the floor - one run is a number, not a comparison - and until then the view says so rather than naming a winner. A candidate with no run carries the control that makes one - this is the only role a bench’s result can promote, and it is promoted here rather than anywhere the page claims a measurement the feed never made. The pick’s own row carries the control that takes it, the same install-then-load press the sweep’s verdict offers, and none at all when the pick IS the model that runs: a switch onto what is already running changes nothing.

The bench scores a model on this machine’s own material, and on one bundled slice. A shipped binary carries none of the person’s audio: a machine with no recordings says so, and the way to get material is to record it - or to score the public slice, which every binary carries. Three tiers. The consensus tier is the takes forge has saved here, the candidate’s words read against the words the model in use recorded beside each one - speed and agreement, and agreement is a signal rather than an error, because the other side is another model’s output. The read-aloud tier is the one passage somebody read aloud on purpose: its words are known, so it is the corpus that scores term accuracy as well as word error rate. The public tier is a fixed LibriSpeech test-clean slice embedded in the binary with its transcripts and digests; the first bench installs it beside the models, with an ATTRIBUTION file written beside the clips, so a machine that has never recorded anything still has something to score. The installer rewrites the slice before a bench scores it whenever the manifest or any clip differs from what the binary carries, so a tampered or missing clip is replaced rather than scored. The page records that passage itself: the section draws the passage and the record control, and a press opens this client’s microphone through the composer’s own capture. The recording is not a take - no session owns it and nothing is transcribed, because the passage’s words are already known - and the card draws its clock, its frames and its levels while it runs, with stop and save and cancel. Every recording is KEPT: with one standing, the section draws the recordings themselves - each one’s length, what it costs on disk, the digest of its own samples and when it was made, with a delete on the row - and a control to add another. The passage is not drawn again, because a reader who has read it does not need it under every state, and the note says what a run over them reads: term accuracy on the passage’s known terms and word error, where the takes read agreement. A run names its target, its own progress, and a stop that discards a partial corpus rather than saving one. Each saved result draws its figures, what it means against the model in use on the same corpus, and a delete.

The other direction has its own card. The speaking model says each public transcript, the transcribing pair hears the audio back, and the words are scored again: round-trip word error, first audio, the loop’s pace and the process’s peak resident set above what it held before the run - a run saved before that figure was measured draws without it rather than as a zero. It sits beside the words bench and never mixes into its results, because the two measure different things - one scores a model on audio that exists, the other scores the loop from words to audio and back. One press scores it, a stop discards a partial corpus, and the newest saved run draws under the card. It scores the same bundled public slice the words bench’s public tier does, so the two directions read on one yardstick.

The benchmark reads in one order. The fixtures come first, in a count line and the set’s own card - what a run scores on, which is the reader’s to change. Then the one press. Then the verdict, the speaking loop’s own card, and under them two doors: every run scored here, and every model a bench can run. Both lists are long and neither is what the section is for, so they fold behind a heading that carries their count. The catalogue above does not fold: it is a lookup a reader opens the page for, not evidence for anything.

The sweep is one press, and it leaves a verdict. The card prices the press before it spends - which runs, what has to come off the network, which corpus they score on and what the last run over that corpus took - and then one button scores them all. The runs are the transcribing side’s own pick, the cleanup role’s most-downloaded candidates, and each role’s model in use as the baseline the others are read against, which is what makes “are you on the best” answerable at all. The chain is the core’s own one-action-at-a-time states: install what is missing, bench each run in turn, and read the verdict off the one corpus every run shares - the corpus the sweep’s own first run lands on, because a result that was already here was measured on the takes as they then were and cannot anchor a comparison against today’s. Once that run has confirmed the corpus, a run already measured on it is kept rather than repeated, which answers a re-press in a moment. The card says where it is while it runs - fetching a file, or scoring one - and a stop leaves what it measured on the rows below rather than pretending it finished.

The verdict names what it saw. A sweep cannot promise the best model; it promises the best of what it ran, and the verdict’s own words carry that scope: the cleanup side says “best of the N most-downloaded cleanup candidates, scored on your takes, N clips”, and says how many candidates it never tried. Where the read-aloud set exists the sweep scores on it, because that is the one corpus a run can be called better on; where it does not, the verdict says which tier it leaned on and points at the recorder above. When the model in use wins, the verdict says that. When something else does, the verdict carries the switch - the same install-then-load chain as an update, into the role the run was read in - and the cost of it, because the files the sweep fetched and nobody adopted go back off the disk: the sweep takes its own downloads back once the verdict is in, so a candidate it did not adopt costs its bytes again if the switch is pressed later.

The note under the check line says what a check is, because the control does not: it reads the catalogue the runtime publishes - every variant with its sizes, licences and the speeds and error rates its maintainers measured

  • and compares that with the models pinned here. Nothing is measured on this machine.

The search is the client’s, and it filters as the box is typed. The whole feed arrives with the read, so there is no submit step and nothing is asked of the server. The rows are the feed’s own figures; the note says they are measured on an m4 max, not on this machine. Each row is a link to its catalogue entry - the feed’s own document, in the same tree the server fetches from - because a list of rows that goes nowhere is what a reader clicks first, and the marker that says so is drawn at rest rather than uncovered by the pointer.

A download is never called verified. The core checks a download against what its entry declares - the byte length always, and the sha256 a cleanup feed’s blobs publish, which the speech feed’s files do not - plus the engine’s own load, and the page’s note says exactly that. Nothing on this page may print verified, checksum or a digest the spec did not declare.

The box is blind on its own, so the page offers what can be searched. Before a name is typed it draws the feed’s own families as chips - each one naming a class the feed holds, with its entry count, most-populated first - and its three fastest measured rows under them. Both set the query, so a pick is the same mechanism as typing rather than a second one. A count is search’s own answer for that name, so the number on a chip is always the number the click draws; and the feed carries no date for a variant, and nothing this read keeps carries one either, so “latest” is not a thing this page can say - its own measurement is the only ranking it has.

A catalogue that has not landed is its own state. With dictation on and no rows - a first enable offline, or the boot fetch still out - the section draws a line saying so rather than a box whose every query would answer “no entry matches” and chips pointing at nothing.

The states the page can be in

  • The ordinary case: both models loaded, a fresh check, nothing to propose.
  • An update available: one line per in-service model worth adopting, which can be one or both dictation roles, each with the control that takes it.
  • Dictation off: [dictate] enabled is unset, so no dictation model is in use, none is proposed, and the feed has not been read at all - the server loads the catalogue only for an enabled section, so the read answers no rows and no check. The speaking role’s own view still draws when [tts] is on, because its model comes from the Hub rather than the feed. It is its own state, naming the key that would switch it on, and the search draws a note saying the same rather than a box that would answer every query with no entry matches.
  • Reading: connected, and the subject’s snapshot has not landed. The page says it is reading rather than drawing an empty catalogue.
  • Refused: the server turned the subscription down, in its own words. The address is not what is wrong, so the door is not drawn.
  • Checking: a check is in flight - this page’s own click, or boot’s - and there is no second Check now to press. A second check is refused, and a control that reports nothing when pressed reads as broken.
  • Unreachable: the last check could not reach the feed. The server’s error is drawn under the line, and the last-known rows stand: a failed check costs the freshness line, not the feed.
  • An unknown check state: a state tag this client is older than is narrowed once, where the read enters (wire/models.ts), into the page’s own unknown - which says so. It never draws as never, which would claim nothing has fetched on a machine that has. The download and activation states take the same treatment, and so does a source for a model in use this client has no case for.
  • A model mid-load: the row’s chip carries the state the preflight snapshot reports - waiting, verifying, fetching with its fraction, loading, loaded, or failed. The page re-reads when the load finishes (dictate_availability), so chips do not stay on waiting until the next check.
  • A download in flight: one line under the header naming the file, the whole percent and the bytes, with a progress element carrying the same figure. Every install and activation control is disabled while it runs - the core takes one download at a time - so a second press is not offered and then refused.
  • A download that did not finish: the same line in the failed tone, with the file and the core’s own reason (a byte-length disagreement carries both lengths).
  • An activation in flight: a line naming the file being loaded and the role it will take, saying that dictation keeps running the current model until the new one is up.
  • An activation that did not load: the failed line carries the reason and says the current model is still running - the core builds the new engine before it swaps, so a failure changes nothing.
  • The sweep before a press: the runs it would make, what they cost, which corpus they score on, and - when no run has ever landed on that corpus - that there is no clock to estimate by. Nothing here is promised; every figure comes off the read.
  • A sweep running: the line names the file it is fetching or scoring, the tier and the run count; the stop is beside it. A stop, or a run that failed, leaves what it measured on the rows below and does not draw a verdict - a verdict from half a plan would be a claim nobody made.
  • A verdict: whether the model in use read best, or which run read better and by what, with the scope in the same words and the switch - and its download cost - where one is offered. It stands until the next sweep.
  • A bench that did not finish: the core’s own reason under the section, with a close on the line. Closing hides that failure - keyed by what failed, so the next one draws - because the state itself is the core’s and the page does not clear what it did not set.
  • A model this machine downloaded and nothing runs: the row carries the control that removes the file and its record, refused by the core while a role runs it. This is what clears a broken download, and what a sweep leaves behind when its candidate lost.
  • A refused action: the core’s own words under the header, on an error frame the connection answered the dispatch with - a second download while one runs, an activation on a role forge.toml pins, or an uninstall on a file a role is running.
  • A pinned role: its row’s source line names the config key - [dictate] for a dictation role, [tts] speaking_model for the speaking one - no row offers an activation for it, and the download controls stay.
  • A role running an installed model: its row carries use the default beside the state chip - the swap back to the compiled pin, which is what undoes a runtime pick.
  • A connection that dropped: the shell’s own line stands above every page, and this one keeps what it last read.
  • A search that matches nothing: the query is named, with the hint that a family name is what it matches on.

The pieces it takes from the pages beside it

  • The header, the brand lockup and the .wrap column, from the home.
  • The band card’s ground - the gradient over --line - as the row’s surface, and the .svc card’s own shape for the check and update lines.
  • The empty box (no model is in use, and the benchmark section) from the home’s own empty treatment.
  • The chip, at this page’s scale, from the dictation panel’s - as a state when it is a span and as an action when it is a button.
  • The state marks: the four shapes are the shared vocabulary (web.css), and the two this page needed - the verdict disc and the verdict triangle - were added to that vocabulary rather than to the page, because the band draws the same two.
  • The .status line, for the download and activation states, is the check and update lines’ own shape; the download’s bar is the platform’s progress element, so the value it draws is the value the words beside it carry.

The two widths

The page is checked at 1600 and at 430, against the drawing beside it. At the sheet’s 760 breakpoint the model row wraps: the role and the chip take a line of their own, and a candidate row’s facts wrap under its name. Nothing overflows horizontally at either width.

Touch

Every control takes the 44px target under @media (pointer: coarse) - the shared rule covers a button and an input, and the page adds the two it cannot reach: the way back to the home, and a candidate row. No affordance lives on hover alone.

Extensions

The page that shows what this forge can run: the plugins installed here, the skills, agents, commands, hooks and language servers they ship, the plugins a configured marketplace offers that are not installed, and the marketplaces themselves. It is drawn from one snapshot of the extensions subject - the inventory, the PATH checks and the last update run - and redrawn from the update stream after it. The drawing it is held against is web-extensions.html, beside this page.

It lives at /extensions, which is its own address rather than a session’s: plugins belong to the machine’s config dir, not to a seat, and the subject carries no slot. The header’s way back is the home.

The read and the actions are the server’s (Subject::Extensions, Command::PluginInstall, Command::PluginUpdate, Command::PluginUpdateAll, Command::PluginCheckUpdates, Command::MarketplaceAdd, Command::MarketplaceRepair). This page is the view of the inventory, and the action verbs live in the core so that any view reaches the same CLI rather than driving it from its own layer.

What it draws

RegionShowsRead from
Headerthe brand mark, forge, the page’s name, and the way back to the homeClientSettings.mark from the greeting; the route
Legendthe three marks in words, once: a triangle is an update, a spinner is work in flight, an X is a failure, and no mark is installed and currentthe page’s own constant
The page’s statea refused action in the core’s own words, and an inventory that could not be read at allrefusal (an error frame); snapshot: null
Railone item per kind - Installed, Skills, Agents, Commands, Hooks, LSP - then Marketplaces under a hairline, each with its count; the Installed item carries a triangle when anything is stalesnapshot.components, counted over INSTALLED rows only
Installedone row per installed plugin: a fold chevron, its state mark, its name, its marketplace, its version (and the marketplace’s when they differ), its counts, and its own actions; the open row shows every component it ships, grouped by kindsnapshot.components; update_run for a row in flight
A kind’s paneone flat row per component of that kind: the name, the plugin it comes from, and (for LSP) the binary check; a row whose plugin has an update carries the Update actionsnapshot.components; lsp
Updates bandhow many plugins are behind the marketplace with their version pairs and Update all; a run in flight with its progress; a finished run’s reportupdate_run, snapshot
From the marketplacesone dim row per plugin a marketplace offers and this machine does not have, with Installsnapshot.components (not installed); snapshot.marketplace for descriptions
Marketplacesone row per configured marketplace: its source, its repo or clone, its plugin count, and its drift or load failure with Repairsnapshot.marketplaces, snapshot.marketplace_health
Install scope pickerthe plugin, the three scopes (user, project, local) and a project for the last twothe roster (the home’s project list)

The rules

Three marks, and words for everything else. Ved, 2026-10-10: a vocabulary of seven shapes is decode-work, not information. A triangle is an update waiting, a spinner is work in flight, an X is a failure whose reason is on the row, and no mark is the ordinary case. auto-installed, restart required and disabled are chips in words, and the legend line says the marks once so a shape is never a guess.

A plugin row folds its components, one at a time. The whole row is the toggle (the hit target is an overlay behind the content, so the row’s own controls stay pressable), opening one closes the last, and the fold shows every component the plugin ships, grouped by kind with counts: skills and agents and commands by name, the hook set with its trigger events, and each LSP server with its own check.

Updates are per plugin, and all at once. Every stale row carries its own Update - one claude plugin update call for that plugin in that scope - and Update all runs exactly the stale set, one call at a time, continuing past a failure and reporting each row’s outcome. A component is never updated alone: install, update and uninstall act on the plugin bundle, so a component row inside a fold carries no control of its own and the head row’s Update is the whole of it.

The rail counts installed rows only. A tab’s count describes what is here, never the catalog’s size: 19 plugins with 74 installed skills shows Skills 74 even where the marketplaces offer more.

Available rows name the plugin, not a component. The install unit is the plugin, so the marketplaces section lists plugins (with what they ship), and the press opens the scope picker rather than installing silently.

An inventory that cannot be read is its own state. No plugins root resolving (CLAUDE_CONFIG_DIR or HOME) is not an empty machine: the read answers snapshot: null and the page says so rather than drawing an empty tree.

The PATH check is the server’s. Whether rust-analyzer resolves is a fact about the machine the socket is on, so lsp[].on_path crosses computed and the page never guesses: a check the server did not report draws no note.

The band is the page’s update state, not the query’s. Filtering the search narrows rows; the count of stale plugins and Update all stay about the whole read, because “there is something to do” must not disappear because of a query.

The states the page can be in

  • The ordinary case: a full inventory, nothing stale, no band.
  • An update waiting: the band names the pairs, the Installed rail item wears a triangle, and every stale row carries its own Update.
  • A run in flight: the band carries the spinner and n of m done, and each row the run is on says updating.... The control a press started waits for it, and a second press while one runs is refused by name above the page - the core serializes plugin actions, and saying so is better than a control that silently does nothing.
  • A finished run: the band’s own report - 3 plugins updated, or 1 updated, 1 failed - with each failed row carrying its reason.
  • A check in flight: the Check button says checking... and the read’s next landing clears it; a check applies nothing.
  • A plugin whose directory is gone: the row draws with an X and its reason, and its fold says so too.
  • A disabled plugin: a chip in words; it is never in the stale set.
  • An auto-installed dependency: a dim chip; an update waiting still outranks it with the triangle and the Update action.
  • A plugin mid-relaunch: a hook whose update applied but has not been consumed draws restart required on its row. The reload is a session’s own - the chip says so and the page does not offer to do it.
  • A marketplace that drifted: the row carries the notice and Repair, which is the CLI’s remove-and-re-add pair followed by a re-scan.
  • A marketplace whose manifest will not load: the failure’s own words on the row, and Repair beside it.
  • The scope picker open: three scopes, a project select for the two that need one, and a note saying the press installs the whole bundle. The confirm press sends - focus lands on the first radio, and the button is the control - and Esc, the backdrop and Cancel close it; the hardware Back closes what it opened.
  • A refused action: the core’s own words above the page, straight off the dispatch - a second action while one runs, a project-scoped install with no project named, a project forge.toml does not declare. An add among them reopens its input with the draft kept, because a refused dispatch is that add’s whole outcome and no plugin frame follows it; the refusal names the dispatch the add’s press made, so a sibling action’s refusal never reopens the add’s input.
  • An action that ran and failed: the core’s own words above the page - a failed update, install, marketplace add or repair, a plugin the registry does not hold in that scope, or a scan with no root to walk, all of which ride the Plugins frames rather than a reply - with the row’s own reason on it where the run reported one, and a rejected add reopening its input with the draft kept, because a source that vanishes with its error cannot be fixed. An outcome frame names the dispatch its press made, so an unrelated action’s frame never disposes of the add’s draft.
  • A search that matches nothing: the lists empty and the counts under them say how many rows were on the page before the query.
  • Reading: connected, and the subject’s snapshot has not landed.
  • Refused: the server turned the subscription down, in its own words. The address is not what is wrong, so the door is not drawn.
  • A connection that dropped: the shell’s own line stands above every page, and this one keeps what it last read.

The pieces it takes from the pages beside it

  • The header, the brand lockup and the .wrap column, from the home.
  • The band card’s ground - the gradient over --line - as every row’s surface, and the chip, at this page’s scale, from the dictation panel’s.
  • The three state marks are the shared vocabulary (web.css): the triangle is .dot.warn, the spinner is the ring, the X is .dot.failed, and the quiet case draws nothing at all.
  • The search field, the band’s shape and the section headings are the models page’s own treatment, and the install dialog is the board’s takeover shape at one question’s size.

The two widths

The page is checked at 1600 and at 430, against the drawing beside it. At the sheet’s 760 breakpoint the rail wraps above the pane as a row of chips, a plugin row’s provenance wraps under its name, and the fold’s component grid becomes one column. Nothing overflows horizontally at either width - the sheet has no sideways scroller anywhere.

Touch

Every control takes the 44px target under @media (pointer: coarse) - the shared rule covers a button and an input, and the page adds the five it cannot reach: a plugin row, a rail item, a marketplace row, the install dialog’s scope row and the way back to the home. The fold’s chevron is drawn at rest rather than uncovered by the pointer, because the fold is the page’s one interaction and a phone has no pointer to arrive with. No affordance lives on hover alone.

Settings

The effective config: every key forge.toml loaded, its value, and when an edit to it takes effect - and the editor that changes those values, adds and removes whole entries, and saves the batch. The drawing is web-settings.html.

Its address

/settings. It is a member of the client’s routes, so a deep link opens it and the desktop and web clients draw the same page.

Where its data comes from

There is no settings read verb. The server pushes the whole document as a settings frame once after the greeting on every connection, and again after every applied reload; the page holds the last frame it was handed. A client that attaches while a reload is in flight reads the current load from the next push, and the attach frame always reflects the current one.

The document is built once per load and shared by every connection. Its values cross unredacted, which is the decision the WireGuard boundary carries. It also carries the fingerprint of the raw file text it was built from, and the edit metadata for every key.

A save is one settings_edit message: the fingerprint, the batch of edits (set, add or remove), and the page’s own id. The server reads the file, compares the fingerprint, applies the edits to its text with a comment-preserving writer, validates the candidate with the boot loader, and renames a temp file over the path. settings_saved acknowledges the save by its id; one that wrote nothing is answered by settings_refused with the loader’s or the guard’s own words. The watcher’s reload push carries the new document to every client. A save with no answer is a timeout the bar reports.

The same catalogue and writer back the settings MCP tools. An agent reads one key or the catalogue through settings__get and settings__index; a settings__set or settings__unset parks as a held write in the composer’s dock, showing every key it would change with the current and proposed value, and applies through this same save path only when the reader approves. One settings__set may carry several keys - a section’s mutually required pair is one validated save - and the batch is refused before the dock if the loader would reject its result. A file that moved between the card and the approval refuses the write whole, so nothing lands against a config the reader never saw. The page and an agent never take two routes to one file.

The speaking card is the page’s one other read: while the voice area is selected it watches the dictate_models subject through the models read, and lets the subscription go when another area is selected. The speaking model, its install state and the voice list come from that read; the chosen voice comes from the settings document above.

What it draws

PieceWhat it is
HeaderThe mark, settings, the server / socket / claude versions, the totals (keys, areas, the resolved forge.toml path), the back link and the edit button.
Sub-navThe thirteen areas, each with its key count and every class its keys hold. On desktop it stays in place while the pane scrolls.
Pane headThe area’s name and note, its key count and, where the area names entries, how many (projects counts projects, not the orgs that carry them); the area’s add doors in edit mode. It stays above the scroll.
A cardOne entry or table (an account, a model, an org or a project, a Slack workspace, a vault, a server table): its name, key count and own doors, then one line per key. Keys with no instance draw one card with no head band.
A setting lineThe key’s leaf, its class badge, and the value right-aligned to the card’s edge on desktop (stacked under the key at a phone width); the whole path is its title. In edit mode the value is the control, the required mark rides the key, and a changed line keeps an accent edge.
The editorThe edit button in the page header turns every value into its control, gives each entry card its remove door (an org card + project instead), and draws the save bar.
Save barThe unsaved count and one claim: a refusal, a conflict, saving, saved, or nothing. A conflict offers its reload even when a refusal outranks it in the claim, and a stale refusal offers one too; a refusal the loader named anchors at its key.
Restart bannerAfter a save lands, exactly the restart-class keys it carried, with no restart button.
A new entryA dashed card headed by its name and new <group>, its fields empty and required-marked, discarded or saved with the batch.
A pending removalThe card head reads removed on save, with Undo; its lines are not drawn.
The speaking cardThe voice area’s own block above its key cards: the speaking model row with its one control - install, activate or use the default - except that a [tts] speaking_model pin on an installed model draws no control, while a pinned model this machine does not have still offers install; the directory the files live in; and a voices card drawn like the key cards, its head band the disclosure (the name, the count, the chosen voice and a chevron, shut at rest). Inside it the voices are setting lines grouped under language sub-headings, scrolling inside the card. Each voice line is its id and the chips: its chosen state or use, and a preview that becomes a disabled preparing while the render runs, then a playing state and a stop, with a failure drawn on that line.
The vault cardThe connectors area’s account block, drawn right after the vault key card and above the device enrolments: whether the shared session is locked or unlocked, the idle bound it locks on (or no idle lock), and a lock door while it is open - the same command from any page, because one vault serves the whole config dir. A locked card says the next vault call asks for the master password or a device gesture. It draws as its own state when no [vault] is configured.
The device enrolments cardThe connectors area’s own block, under the vault’s account card. A this device line carries this client’s own gate state and the one enrol control, drawn whenever the vault exists - the press itself refuses while it is locked - and the press asks for the gesture where the platform binds its key to one (the web passkey, the phone’s auth-bound key). The press replaces this device’s row and key, so it is also the route back from a missed rotation or a stale blob; nothing enrols automatically, so a revoked device cannot re-register itself on the next unlock. Under it the card lists every device the core holds, each purpose the device enrolled, its enrol stamp and its last release, with a revoke per purpose: the revoke drops that purpose’s blob only, so the account and the device’s other purposes are untouched, and the device’s next start is the master password until someone enrols it again. The list is the core’s runtime state, not a forge.toml key; a device with no purpose enrolled is gone from the list, and an unreadable read says so rather than drawing as none.

The rules

  • One catalogue. The class of every key comes from the config loader’s catalogue (crates/forge-workspace/src/settings.rs), which is also what the reload summary reports from. The page cannot label a key differently from the reload.
  • The class is stated once. Each line carries its badge as a shape and a word; there is no page legend and no second copy in a card head. An area’s rail row still reports every class its keys hold, which is a different fact: which classes live in an area.
  • Lines are never skipped. An area, a key, a class or a value shape this client does not know draws plainly with the server’s own word; nothing disappears because a build is older than the document.
  • A line draws its leaf. The pane head names the area and the card head names the instance, so a line draws the key’s own name, which the server sends as leaf; the whole path stays in the document and in the key’s title. A name containing dots, like a model slug, is one name.
  • An area reports every class it holds. A sub-nav row draws each distinct class its keys carry, not only the furthest, so an area holding both a restart key and an unread one says both.
  • The edit door is at every width. It is a 44px bordered button in the page header, on the title row at a phone width, and no width rule hides it. It is disabled while a save is in flight.
  • The bar makes one claim. Its status is derived in one order - refusal, conflict, saving, saved, idle - and drawn from one slot, so two can never appear together. A foreign push clears a saved flash before the conflict draws.
  • The client’s axes come from this frame. [client] theme, font and mark are applied when the document lands, so an open page re-themes, re-fonts and re-marks itself without a reconnect. The greeting still carries the same values for the pre-attach handshake, but only the frame applies them.
  • A frame belongs to its connection. A submit can leave a launch’s socket speaking after the person’s connection took the page; the shell moves the store to the taken connection, so a stale document cannot re-theme the page someone is reading.
  • Batched saves. Every changed value, added entry and removal is one message, one validated write, one reload and one push.
  • A stale save writes nothing. The page carries the fingerprint it loaded; a file that moved since refuses the save and offers a page reload.
  • The loader is the authority. A candidate that would not load writes nothing; the refusal is the loader’s own message, anchored at the edit it names where it names one.
  • The writer preserves the file. A value changes in place with its comments and its order; a new entry is appended; a removal takes only its own table.
  • Guards live on the server. Removing a project with live sessions is refused, naming them, because the reload would leave those sessions running but invisible. Removing an account whose usage probe has not settled is refused until it does, because a retired entry still loading would leave the pool unsettled (#2681). Removing a settled account keeps its bound sessions on the retired credential until they end; removing a Slack workspace leaves its pump on the old token until a restart. The confirm says what each removal leaves behind.
  • No restart button. A landed restart-class key is named in a banner; the server restart is the reader’s.
  • Values are ordinary. A token is drawn and edited as its value, like every other string; there is no redaction here, and the file remains the one source.
  • The speaking card writes once, through the page. A voice is chosen into the draft like any other key and written by the one Save, so [tts] voice has no second writer. Its model controls are the core’s own commands: an install and a switch land as model pushes, not as config edits, and every one of them is disabled while one is in flight.
  • A preview plays on the seat the reader was last on, through that seat’s own voice stream. It needs the seat’s speaker switch on, and the card says so rather than opening a second playback path; a core refusal is drawn in its own words under the card.
  • A preview is one at a time, and it shows. The control is disabled until this client’s switch for the seat is on, because the core plays only for a listener. A press wakes the audio device inside that same gesture - a context a browser holds suspended plays the clip silently - stops the clip sounding now and drops what queued behind it, so the pressed voice starts as its first chunk arrives. The row draws the press: preparing while the render runs, then a playing state beside a stop, with the row’s name taking the same weight a chosen row’s does so the voice being heard is identifiable at a glance; a failure lands on that row in the core’s own words.
  • The voices are a card that starts shut. It draws the key cards’ own card, its head band the disclosure: the name, the count and the chosen voice, and a chevron at rest in both states - the pick stays visible with the card shut. Opening it lists the voices as setting lines under language sub-headings, inside the card’s own scroll so fifty-four rows never run the page off the screen.
  • A voice id is the model’s own name. af_heart is what Kokoro’s API takes; the published files carry no display-name table, so the ids are drawn as published rather than translated.

The states the page can be in

  • Waiting - connected, and the settings frame has not landed. The page says it is reading the effective config.
  • A failed read - the frame arrived and this client could not read it. The page says so rather than drawing an empty config.
  • An unknown area - a selected area the document does not hold (a document from a different forge build can leave one out). The pane says it is gone and offers the first area.
  • An empty area - an area with no keys, which a section that is absent or off can draw.
  • No areas - a document that declares nothing.
  • No connection - the route draws the connect door, as the models page does.
  • Read-only - cards, badges and values, with the edit button as the one door.
  • A long area scrolled - the rail and the pane head stay in place while one area’s cards pass under them.
  • Editing - controls, required marks, the dirty line, and the add and remove doors.
  • Unsaved - the bar counts the batch and Save is the one door.
  • Saving - the bar says so, and it waits for the save’s answer.
  • Saved - the bar says saved for a moment, and the restart banner names the restart-class keys the save carried, if any.
  • Refused - stale (a reload-page door), invalid (the loader’s words, anchored at its key), a guard’s own refusal, or io (the file could not be read or written; nothing was applied).
  • A conflict - another writer pushed a different file while the draft was dirty; the draft is kept, saving is withheld, the saved flash is cleared, and the bar offers a reload.
  • A save with no answer - the bar says no answer arrived and keeps the batch.
  • A new entry - a dashed card with its required fields marked; it is discarded or saved with the rest.
  • A pending removal - the card reads as removed on save, with Undo, until the save lands.
  • No seat, or the switch is off, for a preview - the reader has not opened a session since this client started, or this client’s speaker switch for that seat is off. Every preview control is disabled and the card says to open a session and turn the speaker on; another client listening for that seat is not this client’s preview.
  • A preview in flight - the pressed row’s control is a disabled preparing until its first chunk sounds, then a playing state with a stop; the row returns to preview when the clip ends.
  • A refused or failed preview - the words draw on the row that was pressed, with its preview control back so the press can be tried again.
  • Reading - the models subscription has not answered; the card says it is reading the speaking model’s state.
  • Speech off - [tts] enabled is unset: the card says every reply renders silently and withholds the model and preview controls until the key is on.
  • A [tts] enabled change waiting on a restart - the saved file and the running role disagree: the card says which is on and that a restart applies the edit, the model controls follow the saved value (what the core’s install and switch gates read), and previews follow the running role.
  • A speaking model not installed - the card draws the model row with its install control, and the voices section says they arrive with the model.
  • A pinned speaking model this machine does not have - the row offers install <quant>; [tts] speaking_model names the model to run rather than a reason to withhold the download.
  • A speaking install or switch in flight - the speaking model’s controls are disabled until the core’s own state settles.
  • An unknown speaking state - a speaking read this client cannot parse draws as itself rather than as an installable model.

The pieces it takes from the pages beside it

The shared header (header.top.pagetop), the brand lockup, the type scale, the palette tokens, and the .wrap root. The rail, the cards and the setting lines are its own, scoped under .settings-page, because .row, .rail and .chip are names other surfaces already use; the class badge is the shared components/ClassMark.svelte and its rules are not scoped, so a second surface can wear it. The speaking card takes the shared GroupFold for its voice list and the shared .chip for its controls, because both are vocabulary the pages beside it already draw.

The two widths

Checked at 1600 and 430. At 1600 the rail is a fixed 232px column that stays in place, and the pane takes the rest. A setting line is 40px, or 48px while editing; its value is capped at 52%, and an editing control at 420px. At 430 the header is a grid: the back link and brand first, the title and the edit button second, the build facts beneath. The rail wraps into bordered chips, the pane is one column of cards, a line stacks its key and badge over its value, and an editing control is full width and 44px tall. The page does not scroll sideways at either width.

Touch

The sub-nav items, the back link, the edit button, every add and remove action, the save bar’s buttons, the toggle, the list controls and the editor’s boxes are 44px targets under (pointer: coarse), and the edit button is 44px at every width because it is the page’s only way in. Nothing on the page is hover-only: the selected area carries a background, an inset bar and aria-pressed, every class mark is a shape plus a word, and every control has its own label.

A document

The viewer a handed document opens on: markdown, a PDF or plain text, drawn in the client’s own viewer rather than in the conversation. An agent hands a document with documents__hand_off and the hand-off arrives as an ask in the composer’s dock - the document’s resolved path, and two verbs, Open and Not now. Open lands on the viewer’s own address; a link back to the document - a review thread’s deep link, an address kept - reopens it exactly where it was. The drawing it is held against is web-document.html, beside this page.

It lives at /doc/{org}/{project}/{label}/{path...}, with the document’s resolved absolute path as the trailing segments: the document IS the address, so a hand-off and a deep link spell one. reviewable and the parked call’s id ride the ask’s Open into the route rather than the address itself - an address read back from the URL carries neither, and a deep-linked viewer has no parked call to release. Neither changes what the page draws: the read answers whether the DOCUMENT is reviewable, so a cold open draws the same body a hand-off’s Open does, and the parked call is the only thing the hand-off adds. The page draws no header: the tab names the file, and the document is the whole of what is on screen.

A readable markdown or text document draws as its rendered targets and can carry comments, whoever opened it. A PDF and any other kind open read-only, with no comment affordance anywhere.

What it draws

RegionShowsRead from
The askthe dock’s card: document hand-off, the resolved path, Open and Not nowthe document_hand_off_pending update (and the seat’s pending_asks read, for a client that attached while the ask waited)
The documentmarkdown rendered by the chat’s own renderer; plain text as text; a PDF in the surface’s own viewer; any other kind named as itselfthe document_read answer: document_kind and the bytes
A readable markdown or text documentits rendered targets - a paragraph or heading, one item of a list, one paragraph of a quote - each one a comment target with a + on its own line revealed on hover or focusthe answer’s threads and the page’s own drafts
A comment on a linethe target’s + is the target: hover or focus draws it, and a press opens the composer anchored at that line, the line first in its captured contextthe target’s own source line
A comment on a sectiona press on the target (outside its +) opens the composer anchored at the target’s first line, its whole span capturedthe target’s own source span
Threadsevery turn the thread carries - the reader’s words and the agent’s reply beside them - drawn under the target their placed line lives in; a draft-held echo draws neither, its draft row standing ineach thread’s anchor and the answer’s resolution
A moved threadthe thread at the line its first captured line was re-found on, with moved from N naming where it was writtenthe landing’s line and moved_from
An outdated threadthe thread at its captured line, marked outdated and drawn inline - never collected below the bodythe landing’s outdated state
Could not be placedonly a thread whose captured line is past this version’s last line: its captured lines, the thread’s turns, and the sentence saying whythe anchor’s line against the document’s line count
Editing and deletingedit on a user turn - filed or migrated with its thread - opens the page’s own box over its words, mic and all, and save rewrites it; delete takes the thread or the draft away, asking first. Either way the document is re-read, so the card draws what the store now holdsupsert_review_thread and delete_review_thread; the reply, then the document_read re-ask
Resolvedevery open thread carries resolve beside its delete, writing the same status command the review page rides; the agent’s reply never resolves. A resolved comment collapses to its marker line like every other surface’s, opened by a press, where reopen and collapse sitthe thread’s status, and the page’s own opened set
The answerapprove and send feedback, with one feedback box above them. Approve is the one signal the agent may proceed on; send feedback needs words and sends it back to revise and hand off again, and the row says so under the box. The box’s words are the answer’s note and the review’s description; either answer files the drafts as one round and answers the parked callsubmit_review’s reply, then answer_document_hand_off with the chosen ending, the review id and the note
The donea deep link has no call to answer, so it keeps one done (n) that files the drafts, pushes the review-ready line and returnssubmit_review’s reply
The chat lineReview #N ready on the document - scope doc:<path>. Read it back under that scope, or hand the scope to your lead if you do not have the review tools, the page’s own nudgethe page’s own dispatch after the round is filed
A refusalthe server’s sentence as its own line - missing, unreadable, over the ceiling, outside the session’s tree, a text kind that is not UTF-8 - and the two answers still draw for a parked callthe error naming document_read
OpeningOpening <path>... while the read is outthe page’s own state
Emptyits own line, before any kind is drawnan answer with zero bytes

The rules

The hand-off is an ask, and the answer is the reader’s decision, not the open. The raise lands as the dock’s card; Not now answers the parked call - the agent is told the reader declined and moves on - while Open is the client’s own route and answers nothing. The call releases on one of two explicit outcomes: approve or send feedback. Neither is a bare done: a reader who chooses neither leaves the call parked, and nothing a press on the body does releases it. Both carry the one feedback box’s words, which reach the agent verbatim with the answer, and the review the finish filed; an empty press on send feedback draws the requirement and sends nothing rather than disabling the button. A detached client leaves the call parked, and the ask is re-readable when a client returns; only the waiter’s own drop abandons it. A forge with no client attached never parks at all: the tool answers that nothing can open.

The loop is the shipped text’s. The tool description and the session conduct block both state it: feedback means revise the document and hand it off again, and the agent keeps handing it off until the reader approves; approval is the one signal to proceed, and an agent never proceeds on feedback. The page needs no board link for it - the answer itself drives the loop, and the agent raises the next hand-off after every feedback.

The answer is matched by seat AND document. A client holding two seats can have two viewers’ reads in flight; the answer names both back, and a page that matched only on the path would draw one seat’s document in the other’s viewer.

Every refusal is its own sentence. The server is the one place that knows why a read did not happen, so the client draws its why rather than composing a reason of its own - including the connection being down, which is the page’s own sentence because nothing on the wire could carry it. A text kind that is not UTF-8 is refused at the READ: decoding it here would draw replacement characters as if they were the document.

A PDF opens read-only, and the reviewable flag on one is refused with the reason. The refusal is the read’s own and happens before any raise: the hand-off tool answers with the sentence and nothing asks the reader, and an address read back from the URL carries no hand-off, so the viewer never asks for one reviewable - the sentence reaches a reader as the tool’s answer and as a raw read’s error, never as a drawn line, because the kind is the path’s extension and a .pdf path is refused at the hand-off itself. A PDF rides the platform’s own viewer: WKWebView renders one natively (the desktop app; measured), every browser does (Chromium measured), and the Android WebView is the one surface with no PDF viewer of its own, where pdf.js draws the pages from the same bytes the read answered with. Safari is the same WebKit engine that was measured; Firefox ships its own pdf.js.

A comment anchors to a source line, or to a target’s span. A reviewable document draws as its rendered targets: a paragraph or heading, one item of a list, one paragraph of a quote (a table stays one target). Each carries the source span it came from and owns the blank lines up to the next target. There is no always-visible numbering: the target is the section target, and a + on its own line is the line target, drawn on hover or focus (and drawn always on a finger-driven surface). A press on the target anchors at its first line with the span captured as its context; a press on the + anchors at that exact line with the line FIRST in its captured context, capped at 12 lines - the line and the ones after it. That first captured line is the thread’s identity across a revision. The anchor also carries the line’s own hash, the same FNV-1a a code anchor hashes its line with. The highlight is a neutral ground, never an accent bar, and the + is a plain mark in the margin rather than a chip. A plain-text document has no blocks, so its rows are its own lines, drawn as the text they are, each with its own +.

A thread re-anchors across a revision the way a code thread does. The server resolves each doc thread against the document it is read with: the first captured line’s content is looked for - in place at the recorded line, otherwise one unique match moves it - and the landing crosses on the read. A moved thread draws at its new line with moved from N; a thread the revision left nowhere draws at its captured line, marked outdated and still inline, because collecting it below the document would hide what it was about. Only a thread whose captured line is past this version’s last line collects at the end, with the reason said in words. A thread the landing MOVED is not drift and is not aged in the store; one with no landing is, so the review record reads what the reader sees - except a draft-held echo: it is not drawn and not aged while its draft row stands in, and a draft whose line no row holds draws only as its echo.

A comment’s words render as markdown, through the chat’s own renderer - the same Prose the review page’s turns draw with, the author’s lines kept: a comment carrying backticks or an emphasis reads here the way it reads in the conversation. Every turn of a thread is drawn with ThreadTurn, so the round chip and the edit act are the review page’s own.

The drafts outlive the viewer. Unsent comments are held per document, module-wide, so a second hand-off that raises another document does not take the first’s unsent comments with it - they wait in their own scope until an answer files them.

The comment box and the feedback box dictate, the way the composer’s does. The comment box wears the composer’s own mic control, and the feedback box does too: a press starts a take, a hold opens what dictation is set to, and the take’s bar takes the box’s own line while it runs. The words land at the end of the box that was open when the take settled, read off the seat’s record. The comment composer is the exception to a closed box: words submitted before the reader closed it reopen the box at the row and line that began the take, where the reader sees them. The edit and feedback boxes no longer draw once closed, so their words are dropped rather than written where the reader is not, and a notice about a take this page never watched is not placed at all. The push-to-talk key is honoured while a box is open, the same press and release the composer’s box reads.

One feedback box is the description and the note. A single box sits above the answers when a hand-off is parked or there is a review to file. Its words ride the submit as the round’s summary and the answer_document_hand_off as the note, so an agent without the review family still learns what the reader said. Approve may go with it empty; send feedback requires words, and the row says so in a hint under the box - an empty press turns that hint into the warning and keeps the button in the tab order.

The done is the review-left event on a deep link. done (n) submits the drafted threads as one round through the same command a code review uses, under the document’s scope (doc:<path>), stamped with the asking seat as the origin - and then returns to the session, with no call to answer. The terminal’s own drop is the model for the line it leaves: the same one-line drop, Review #N ready on the document, goes into the chat, with the document’s scope named - so the reader sees the drop in the conversation and the agent hears it. The line names no review tool and promises no answer that follows. A refusal files nothing and answers nothing: the call stays parked, the drafts stay where they were, and the refusal’s words are what the page draws.

The states the page can be in

  • The ask waiting: the dock’s card, ahead of the other prompts the way the seat’s own read orders them; a resolution from another view clears it. The seat’s own mark is the ask’s too - the rail reads it as needs-you (“a document hand-off is waiting”), and the seat’s activity mark is Attention while it waits, exactly like a permission or a question.
  • Markdown: rendered with the chat’s renderer - raw HTML never reaches the page - as its rendered targets, on the sheet below.
  • The sheet: the document rests on the app like a page on a desk - one wide centred panel that uses the window’s width, a hairline over every section, the reading scale the drawing beside this page carries. The page background stays the window’s width. A PDF is the exception: it takes the window.
  • A readable markdown or text document: its rendered targets, each one a comment target with a + on its own line shown on hover or focus, its comments placed under their targets and its drafts under theirs, a draft-held echo drawn by its draft row instead - a cold open included, which draws the same body a hand-off’s Open does.
  • A comment being written: the composer opens under the target, with its own comment and cancel pair and its mic; a written comment draws as drafted until an answer files it.
  • A take running: the take’s bar takes the box’s own line - the composer’s own reading, ready, recording and transcribing - and the words land at the end of the box when the take settles.
  • An answer sending: the pressed button says approving... or sending... and both disable while the round files and the call is answered.
  • A refused answer: the core’s own words beside the answer row and both buttons live again. A refused FILING keeps the drafts exactly where they were; a refused answer after the filing keeps the review it filed and its id, so the next press still names it.
  • Approved: approved - review left (R{n}) - the agent has it.
  • Sent back: sent back - review left (R{n}) - the agent has it.
  • Done (a deep link): done - review left (R{n}).
  • A moved thread: drawn at its new line with moved from N beside its mark, the agent’s reply still in the thread.
  • An outdated thread: drawn at its captured line, marked outdated, its captured lines kept.
  • A thread with nowhere to draw: its own section at the document’s end, with the captured lines and the sentence naming the line it was written at and how many this version has.
  • Plain text: wrapping rows, decoded once and each one a comment target with its own +; markup in it stays text.
  • A PDF: the platform’s embed over a blob of the bytes, sized to the page. On the phone, pdf.js renders each page to a canvas at the column’s width; a failure in that render draws its own line rather than an empty page.
  • Any other kind: named as itself - This is a .rs document; the viewer draws markdown, PDFs and text. - and a name with no extension says that instead.
  • No bytes: The document is empty., before any kind is drawn.
  • A refusal: each its own line, straight from the server’s sentence - and the answer row still draws for a parked call, so a refused read returns like any other state.
  • A read that could not be sent: the connection is down, in the page’s own words - a silence that reads as still loading is worse.
  • A connection that dropped mid-read: the reopen asks again rather than leaving the page opening for good.

The pieces it takes from the pages beside it

  • The .wrap column and the shared .pending line, from every page.
  • Prose, renderProse and renderProseTargets are the chat’s own: one markdown upstream (markdown-it, raw HTML off), one set of .prose rules, so a document and a quote from one render alike - and the targets a comment anchors to are rendered by the same instance the body is.
  • The comment path is the review page’s own: upsertCommand, submitCommand and statusCommand from review/write, and ThreadTurn for every turn - one writer, one vocabulary, so a doc comment and a code comment are one kind of thing on the wire.
  • The dictation pieces are the composer’s own: the Dictation take, the DictateControl mic, the TakeCard bar, the DictationPanel and the Landing memory, all under composer/ - one microphone, one take, one reading of what a take is doing, shared with every box that takes typing.
  • The ask’s card is the dock’s own machinery: the same pending_asks read, the same parked/resolved pair, and the same command-bus answer a browser hand-off rides.
  • The call’s own chat row draws under the documents family - the name’s first segment is the family, so mcp__forge__documents__hand_off gets its glyph and its row line like every other forge tool.
  • The page’s own classes are doc-* under a .doc-page scope - a new component takes its own stem rather than reusing a bare name the sheet already styles.

The widths

The sheet is a wide centred page and the page background is the window’s. .doc-body is capped at 1400px and centred with margin-inline: auto, so the document uses the space rather than reading as a narrow column with a desk of empty room beside it; the answer row is capped to the same width. Prose runs the sheet’s whole width - a paragraph, a list and a heading fill it - and code fences, tables and long unbroken tokens take it too. The chat’s own table rule stacks a table when its column is narrower than 760px; above that a table draws as a table, and the phone’s sheet keeps the stack. The document route is its own scroller and wears the theme’s one scrollbar skin, its corner included, so the platform’s white block no longer shows at the bottom-right. At 430 the sheet takes the page’s padding, the table stacks at its container width, and long tokens wrap (anywhere) - the sheet has no sideways scroller and this page brings none.

Touch

Every control the page carries takes the 44px target under @media (pointer: coarse): a rendered target (the section comment target), the + on its own line (the line comment target), the composer’s comment and cancel pair, the answer buttons and the one done. Nothing lives on hover alone: the + is drawn always on a finger-driven surface, the target’s own press opens the section composer, the +’s press comments on that line, and the keyboard reaches both with a focus and Enter. The read-only states carry only the answer row (the platform PDF viewer’s own chrome is the viewer’s).

The browser surface

The server’s Chromium, as a full-window takeover under a slim client bar. A browser hand-off’s Open and the strip’s browser row both open it; the sessions drive the same tabs while it is closed, and opening one shows the tab as they left it. A hand-off opens by its page’s id, not its tab number: a parked ask can outlive the host that minted the number, and the successor’s first tab takes number 1 again, so a page id is what makes Open raise the page the session prepared. The drawing it is held against is web-browser.html, beside this page.

The browser is the server’s. The server ships its own CEF host - a signed Forge Browser.app beside forge’s machine-local state, not in the client’s bundle - and runs one windowless host process per org profile, with a vendored Playwright driver speaking MCP to that host’s DevTools endpoint. A profile comes from forge.toml: the org’s browser_profile (default default) with the project’s own override, so a session’s calls drive <org>/<name> and no client names or picks one. The host owns the profile’s tabs and encodes each tab’s painted frame as a WebP still onto a local socket; the server relays the newest one to every viewer of that tab. The client carries no engine, no driver and no browser profile: it is a viewer.

The phone and the web are viewers too. The same frames, the same bar, the same Take control: the page paints each frame on a canvas where the desktop’s shell lays a native view. The canvas keeps the picture’s shape: a wide page in a portrait phone is scaled to fit with the takeover’s own background above and below it, and taking control lays the page out at the phone’s shape, so the bands fall away. A finger drives the page the way a mouse does: a tap is a press and its release, a one-finger swipe is a wheel, and a tap on the page raises the phone’s keyboard - its named keys, inserted text and IME compositions cross as the same key and text events the desktop overlay sends. The bar wraps on a finger’s screen: a hand-off’s row is wider than a portrait phone, so its actions take a line of their own and Done, send feedback and Not now stay reachable at 393 and 430 points. The web page is the same client with no shell at all: its takeover is a fixed overlay the page owns, its canvas is the picture, and every call the desktop makes to the shell - attach, list, detach, control, navigate, resize - is the socket’s own message instead. No client has a WebView engine or an in-process node any more.

What it draws

RegionShowsRead from
The hand-off’s cardbrowser hand-off · tab N; Open browser, Done and Not now before the takeover; while it is up, the card says the browser is open over the window and leaves the actions to the bar. Words a lowered takeover handed to the chat are named here, since the composer is behind this card until the ask is answeredthe browser_hand_off ask, and the page’s own surface state
The takeover’s barback to chat, the page’s back and forward arrows, the tab picker, the title (the browser · <org>/<name>, or browser hand-off · tab N), the pane chord’s hint while the page holds the keyboard, take control / release control, Done, send feedback once the note has words, and Not now; try again while the surface is in its failed statethe page’s surface state, the parked hand-off, and the pushed browser_view and browser_tabs frames
The ask on the barthis seat’s parked hand-off: its own words under the bar row, with the tab its title names. Shown whenever the page is this seat’s; Done and Not now are offered only while that page is up on the tab the hand-off named (live, or failed with its host gone), send feedback joins them once the note has words, and the note line is drawn in every phase; a page raised for another seat is lowered when that seat is leftthe browser_hand_off ask, and the page’s own surface state
The note linea text field and the dock’s own mic, open whenever the ask is parked: the note is optional for Done and Not now, and send feedback is offered once it has words, the words going to the agent as a prompt and the hand-off ending as feedback - they spoke, so nothing is recorded as a decline. A take begun at the bar’s mic, or by the push-to-talk key wherever the caret is, lands in this line, and a take already running ends on that same key. The line holds those words in every phase the takeover stands - opening, failed, or showing another tab - and showing them is not answering, so only the answers wait for the page. Lowering the takeover hands them to the chat’s draft, and the hand-off’s card says so; an answer does not - the words left for the agent, so the line clears and the draft gets nothing. A take replaces the field with the dock’s own card, and the card takes the line: it grows to everything the mic leaves, so its graph is the row’s meter rather than a pill in an empty row. A slow open says so in a line above the row: one brief enough to be the press itself draws no line, and a longer one says the microphone is opening. An answer whose note the connection never took says so in that same line, in the refusal tone - Not sent - the connection is down. - and the words stay in the field: the bar does not clear what did not leave, and the hand-off stays parked for the retry. The card draws from this page’s own take, which exists only once the capture has opened, and the composer’s own band is behind the picturethe page’s own state, and the take it is opening; the prompt rides the socket
The surfacethe viewed tab’s page, filling the window under the bar: no window, no browser tab strip and no browser chromethe server’s relayed frames for that tab - drawn by the desktop’s native overlay, or by the phone’s and the web page’s canvas
The strip’s rowbrowser with its tab count, a dot when the host is connected (an idle dot when only the host is up, a cross when nothing runs), and a hover naming the profile, the connection and closing in Nm; its panel lists the tabs with show / hide. The row is drawn only while the profile has a tab: a browser nothing has invoked is no row at allthe browser_tabs ask and its pushed frame
A tab rowthe host’s number, the title (else its URL), the owner as org/project/label, and its state - in use, idle or held - with the mark that says which; a tab holding a page dialog adds <kind> waiting beside it, with the dialog’s message on hoverthe tab list’s rows
The engine linethe panel’s own line when no close is scheduled: the server's own Chromium, on every client, or nothing to run a pagethe tab list
The statesopening the browser…, the server’s sentence when it failed, the host’s own death sentence - the browser host exited (signal 9; pid 1234); the next call brings it back - when a host dies under the surface (the cause is a signal or an exit code), and the page’s own error page when a load failsthe page’s surface state, and the server’s failure frame
A refusalthe server’s own words: tab N is not open, the profile has no tab to show yet, the host is not answering, a page area that could not be placed, or - for a hand-off - the page the hand-off named (X) is gone from profile ... with the tabs open now, which its try again reaches too. A take the bar’s mic pressed and could not open says why above the note row: the composer’s own notice row is behind the picture while the takeover is upthe attach’s answer, the surface’s failure frame, and the take’s own line

The rules

One host per org profile, and the host outlives the surface. A profile’s browser is a server process of its own, started on its first real need - the first seat spawned under the profile, a session call, a hand-off’s Open, the strip’s show. Closing the surface ends nothing: the sessions keep driving the same tabs, and the next Open shows one as it stands. An idle close ends the host and its tabs and keeps the profile’s cache and logins; the next call starts a fresh host. Every page call logs its seat, tool, profile, host pid, port and cache root, so a call’s browser is answerable after the fact.

A profile is forge.toml’s, not a seat’s. Its key is <org>/<name>: the org’s browser_profile (default default), or the project’s own override. A session’s calls drive that profile and land on its own tabs. There is no profile verb: no client names, closes, deletes or creates one, and the segment’s list is tabs rather than profiles.

The host owns the tabs, and a tab belongs to the session that drives it. A tab carries the host’s number, and the registry keeps its owner, its title and URL, and the session’s current tab. A session’s first page call creates its tab - or adopts the host’s own boot tab - and every later call drives that tab, unless a page’s own dialog holds it (see the rule below); another session’s calls never land on it. A page’s window.open opens a tab of its own, owned by the opener’s session and made that session’s current tab, so a sign-in popup is driven like any other page; a popup that closes itself hands the session back to the tab that opened it. Three things close a tab with its session: the sweep of dead seats, a /new or /resume on the slot, and the host ending - except a tab a parked hand-off still holds. Nothing outlives the host.

A cross-origin popup is the same kind of tab, opened by the host. A top-level page’s first sign-in open on another origin gets a working window back: the host starts that address in a browser of its own, so the engine has no popup target to share the opener’s renderer with, and the attached driver keeps working. window.opener in the popup is a matching proxy and postMessage is relayed in both directions with each side’s own origin checked at delivery, so an OAuth flow’s result reaches the app as a JSON payload. A noopener or noreferrer open answers null and its tab has no opener unless the page sets the shim’s window.name marker itself. A second cross-site popup from the same page does not commit while the first is live (#2869), a bridged refused open answers a live proxy and the refusal arrives later as a refused event (#2898), and one page may hold sixteen popups at once. A subframe is never given the bridge of its own: its window.open answers null for every web address but the top frame’s own origin, and a cross-origin subframe answers null for every web address. A same-origin subframe can still reach the top window’s bridge, and the engine reports that call as the top frame’s, so it acts as the page does. A same-origin popup keeps the engine’s own popup path and its real, scriptable window.opener. The takeover follows a popup in: a surface showing the opener moves to the popup when it appears, and hands back to the opener when the popup closes, so a sign-in is seen without hunting the strip. The hand-off ask stays parked on the page it named, and Done and Not now come back with the surface when it returns to that page.

A same-origin popup’s own document is the one the host starts. Its target answers no page-domain command until it has a document, so the passkey veto - the script every other page starts with - cannot be registered on it before that first one, and a popup an attached client holds commits exactly one navigation, so its address cannot be preceded by a blank. The host starts the address the popup asked for as that first document; the veto patches that document’s world as soon as its context is reported, which is the out-of-band case a tab another client creates directly at a URL is already in. A bridged cross-origin popup does not need this: the host creates a browser of its own and starts the address once the bridge role is in place.

The browser offers no WebAuthn: its pages expose no PublicKeyCredential, so passkey and security-key sign-in stop working and a site that checks for the API asks for a password instead.

The old profile trees go once. The first server that finds no marker deletes the client’s old in-process CEF caches - browser/cef-profile and browser/profiles/<name>/cef under its bundle identifier - and the server’s own pre-org shared/ and per-seat profiles/<name>/cef trees, then writes legacy-profiles-deleted beside the root; a later boot does nothing. Org profiles live one level deeper, at profiles/<org>/<name>/cef, and a stray file under profiles/ is not a profile.

A hand-off names a tab. browser_hand_off requires the page id of one of the session’s own tabs, as browser_tabs prints it, and Open raises that tab. A page id no tab carries is refused with the tabs as they stand. The tab is held while the ask is parked: the idle clock and the dead-seat sweep both leave it alone, the strip marks it held, and the hand-off’s card names it. Any answer releases it - Done, Not now or feedback - and a session that went while the tab was held takes it with it when the ask resolves.

A page context that dies is rebuilt and answered in the client’s words, never handed back as the driver’s _page TypeError. A driver that cannot attach within its 30 s bound is replaced; a host no fresh driver can list is named and ended, except that a host another session’s page is open on is left running and the holder is named back instead; when its endpoint has stopped answering, the idle clock releases it, or the project lead’s browser_end_host does when the profile is shared.

Every viewer is a watcher until it takes the role on that tab. A surface attaches to one tab and receives every frame that tab paints; exactly one attached viewer holds the controller role on it, and only its gestures and history arrows reach the page. The layout follows the role, and with nobody holding it it belongs to the tab’s longest-watching viewer - so a page with no shell still lays its own view out while it is the only one watching, and a second watcher never moves the page under the first. The bar’s take control moves the role to this surface (the previous holder becomes a watcher at that moment), and release control gives it back with nobody holding it. A watcher’s arrows are drawn disabled: the move is the controller’s, and the server refuses one from anyone else. The server pushes who holds it to every viewer of the tab, so two surfaces draw the same truth, and each knows its own viewer id - the bar reads whose role it is rather than guessing. The bar also names the browser it is over - the profile’s own key - so two devices driving two profiles cannot read as one browser with a role held twice. The bar’s picker moves this surface to another tab of the same profile: a re-attach on that tab, which replaces what this connection was watching.

The picture is the server’s own frame. The host paints windowless and encodes a WebP still; the server relays the newest frame for the viewed tab to each viewer, and the client decodes it - into a layer at the page area’s rect on the desktop, onto a canvas on the phone and on the web. Every client keeps the picture’s shape: a frame laid out for another viewer’s surface is fitted with bars rather than stretched, and taking control (or, on the web, being the tab’s longest-watching watcher while nobody drives it) lays the page out at this surface’s shape so the bars fall away. There is no screen capture anywhere, so the Screen Recording grant, the picker and its monthly re-approval are not part of this surface.

A page with no shell draws its own takeover. On the web the overlay is a fixed box the page owns - the bar at the top, the canvas under it - and every call the desktop makes to its shell is a socket message instead: an attach names the seat and tab and its reply says which profile and tab it took, the tab list’s ask subscribes the push that follows, a lower is a detach, the role is a control message, the arrows are a navigation, and the surface’s size is a resize. There is no rect to report, because the page’s own box is the surface; the canvas still draws only the viewed tab, and a key or a click rides the same input messages the desktop and the phone send.

The row exists only while there is something behind it. A profile with no tab is a browser nothing has invoked: the segment draws nothing at all rather than an empty row saying 0 tabs, and the list’s own pushes bring it in with the first tab and take it away when the last one closes.

Show and hide are this client’s own. The marks come from the server’s tab list - the row’s dot means the profile’s host is connected (an idle dot means the host is up with no driver), and a tab’s own mark says in use, idle or held - but each row’s button reads this page’s own surface: a phone or a web page shows show while the desktop holds the picture on that tab, and lowering here gives only this client’s viewer back.

A windowless page must stay a visible page. The host asks CEF for a visible, focused page (WasHidden(0), SetFocus(1)) and drives its frames from an explicit begin-frame cadence of one every 8 ms - CEF’s own scheduling under an external message pump painted the same page at about half the screen-refresh rate. The sessions’ tools - navigate, snapshot, click, type, evaluate - are the same vendored Playwright battery over the same CDP connection; nothing on the wire changed.

A snapshot’s refs belong to the snapshot that minted them. A ref names an element that was visible and pointer-interactive when the snapshot ran, and it survives an ordinary re-render that keeps that DOM node. A page that keeps replacing its elements retires refs before the next call, so it is driven by a unique role/name selector such as role=button[name="Save"] - scoped to a row when names repeat - which is resolved when the call runs; a name shared by several elements is refused rather than guessed at. Once the page has settled, a full-page browser_snapshot with no target and its refs hold: it keeps the ref of an element that stayed and carries the same role and accessible name, gives none to one that is no longer visible or pointer-interactive, and mints a new one when the role or name changed. A target-scoped snapshot answers with that subtree alone, so refs outside it do not resolve until a full-page snapshot.

Input is trusted input. The person’s mouse and wheel are mapped by the SERVER to CEF’s own mouse calls at the page’s own scale, and keys and text to CDP Input.* events: a click is a full pointer sequence, a wheel is a pixel delta, printable text is inserted as text, the specials (Enter, Tab, the arrows, Escape) are key events, and Cmd+V reads the pasteboard as text. The phone’s canvas sends the same gestures from its own pointer events. A gesture’s point is scaled from the surface’s layout into the page’s viewport, which need not be the same size. Widgets that gate on trusted input commit, which is the whole reason the events are replayed rather than synthesised in the page. The desktop overlay claims the menu’s chords while it has the keyboard. AppKit offers key equivalents before the page’s keys, and Tauri’s default menu takes Cmd+A, X, C, V and Z (and Close Window on Cmd+W) even with the surface focused. So while the overlay holds first responder, those five go to the page - Cmd+V as the pasteboard’s text - Cmd+W lowers the takeover the way back to chat does, and Cmd+[ and Cmd+] move through the page’s history. With the bar’s own note field focused, the menu keeps its editing as it does everywhere else, and every other Command chord belongs to the app.

The keyboard moves between the page and the bar on the pane chords. Cmd+Option+Down or Cmd+Option+Up moves it to the other pane - the two keys macOS Chrome uses to cycle between its toolbar and its page, and with two panes either direction lands on the other one. While the page holds the keys the bar names the chord: the title row carries Cmd Opt Down and the region declares both chords as aria-keyshortcuts, so a keyboard reader can find the door it cannot see. The hint goes once the chord has been used. The bar is a document above the picture, not part of it, so with the keyboard there Tab stays inside the bar (it wraps at both ends), its buttons take Enter and Space, and the note field types as any field does. The bar holds the DOM focus only while it has the keys: on open the native page keeps them, so no ring sits on a control the keyboard cannot reach, and clicking the picture takes them back the way the chord does. A focus that lands in the bar - the push-to-talk key’s line, a take’s landing - brings the keys with it too. A failed page has no picture to hold them, so the bar takes them.

The arrows are the page’s own history. The server reads Page.getNavigationHistory over the same CDP connection that carries the input - on attach, on every re-open, and after every navigation the page commits, including a same-document one - and pushes the two booleans to the bar. An arrow is disabled exactly when the history has no entry that way; back and forward call Page.navigateToHistoryEntry with the neighbouring entry’s id. A query that cannot be read is no news: the arrows stay exactly as they were rather than guessing a new state. There is no URL bar: the history is the page’s own, and the arrows are its only map.

A page’s file chooser is the controlling client’s picker. The host has no window of its own, so the page’s chooser - captured by the driver’s Playwright when one is attached, or CEF’s own dialog when none is - goes to the controlling client as a browser_file_dialog, and that shell opens the platform’s own picker: the desktop’s panel, the phone’s file browser. Only the page’s current tab is asked; a chooser on a background tab is left alone, because the upload answers the current tab. The chosen files’ bytes travel back to the server, which writes them beside the profile and hands them to the captured chooser. A dismissed picker cancels it, so a page never blocks on a panel nobody can see; only the controller is asked, and a watcher is not cancelled by anyone - it simply ignores the ask. Past sixteen files or 8 MiB the whole answer is a dismiss, and the cap is 8 rather than 32 because the answer crosses as base64 in one socket message: the socket’s inbound bound is 16 MiB, so 8 MiB of bytes is what really arrives instead of a promise the connection tears on. On the desktop, Cmd+A, X, C and Z send the page’s editing commands and Cmd+V is a text paste. The phone’s keys, inserted text and IME compositions reach the page through the same input path (above); a rich clipboard and context menus are not part of this cut.

A page’s download is saved by the controlling client. The host writes the file beside the profile and announces it as a browser_download; the shell opens the platform’s own save door and writes the bytes there, or says why it could not. A file past 32 MiB is refused in words rather than handed over, and only the controller of that tab is sent one. A drop the backlog lost names the profile rather than a tab: that notice carries no tab, says how many finished downloads were dropped and that the profile’s backlog holds at most four at a time, and its title reads one loss or many from the count.

The page’s sound plays at the controlling client, for the viewed tab. The host relays what the page plays as PCM packets beside the frames, each carrying its tab; the shell plays the tab it is showing and drops another tab’s packet, on this machine - the desktop’s audio engine, the phone’s web audio. A watcher is looking at the page rather than driving it, so they hear nothing.

A recording is the agent’s, and the person sees its layout. While an agent records the page, the page is laid out at the file’s own 1280x720 and put back when the take ends, so the file is the layout the page had rather than a scaled copy of the surface.

There is no external-browser door. Every page lives in the server’s engine and nowhere else; no client hands one to another browser.

A host that ends takes its tabs. A restart, an idle close, a stop and a lead’s browser_end_host all end the host, and its tabs end with it: the next call starts a fresh host, whose boot tab the first page call adopts before navigating. A profile’s cache and logins stay, so a sign-in survives the host; a filled form does not.

An idle browser closes on the SERVER’s clock. After [browser] idle_minutes (30 unless the config says otherwise) with no call and no viewer attached, the profile’s host is closed - there is no warning and no second grace clock. A viewer attached and a tab a parked hand-off holds keep 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 close ends the host and its tabs, not the profile’s cache or logins.

The catalogued engine’s limits are named, not hidden. The prebuilt CEF has no H.264 decoder (the job sites the probe exercised do not need it), it presents plain Chromium’s user agent and brands (a real browser’s identity, not an automation tell), and its frames are software-rendered (measured at 60 frames per second at the surface’s size; CPU was not profiled).

A failure is drawn where the person is looking. The takeover’s page area carries the server’s sentence and the hand-off stays parked; the strip says what a refused show or hide said. The bar’s note line is always open. Done is the only approval, and it takes the note with it when it has words; Not now defers the ask and takes the note too - the browser is as it was, and the agent asks again later if it still needs the person on the page. send feedback is offered once the note has words, sends them as a prompt and ends the park as feedback, which the agent reads as “something is off”: it addresses the note and hands the page off again for another answer. An answer whose note did not leave says so in the bar’s own line and ends nothing: the words stay in the field, and the park waits for the retry.

An answer belongs to the page it answers. The takeover is one seat’s page: leaving that seat lowers it, so it never sits over a seat whose ask is waiting. The bar carries the ask while the page is the asking seat’s, and offers Done and Not now only while that page is up on the tab the hand-off named, with send feedback once the note has words; a page moved to another tab names the ask and answers nothing. The note line is drawn in every phase, with the words held. A failed page stays answerable: a host that died under a parked ask is what the note line is for.

A page’s dialog is held until someone answers it. A confirm, an alert or a beforeunload prompt takes the tab it was raised on: every call that would drive that tab is refused by name - the tab, the dialog, its message and the seat that owns the tab - before it reaches the driver, so nothing attaches to a paused renderer. browser_handle_dialog answers or dismisses it, browser_tabs close and browser_close dispose of it by closing the tab, and the listing is unaffected. A tab with a pending dialog is unusable until it is answered, which is the price of never answering a person’s modal behind their back. The tab’s row carries the dialog’s kind beside its state, so the person can see why the page ignores input.

The widths

The takeover is the whole window, under a bar that is one row at a mouse’s width. A narrow window or a finger’s screen wraps the actions onto a line of their own - a hand-off’s row is wider than a 393-point phone - so Done, send feedback and Not now keep their 44px targets and stay on screen; the page area is the rest.

The takeover is a layer in the window the person already has, so the title bar and its traffic lights stay where they are and leaving is the ordinary window. Back to chat, the strip’s hide and the phone’s hardware Back all lower the layer the same way, whatever opened it, and the strip and the dock return with it. On the web it is a layer over the tab’s page, fixed to the viewport, so the chat is still under it and Back to chat returns to it without leaving the app.

The terminal surface

One terminal per seat, in the forge server process: the PTY, the screen model and the scrollback are the server’s, the client draws with xterm.js, and bytes cross the socket as their own binary frames. It is hidden by default and raised by the strip’s show or a terminal hand-off’s Open, the browser takeover’s sibling. The drawing it is held against is web-terminal.html, beside this page.

The terminal belongs to the seat, not the occupant. It is created lazily on first use and carries the seat’s own cwd and environment - a worker in a git tree runs in its worktree under the tree it was spawned on, a worker in a non-git tree in that tree; the lead in the project directory - and /new, /resume and a handoff leave the PTY, its shell and its scrollback untouched. It lives as long as the seat: releasing the seat ends it, and an explicit terminal__close, the shell exiting, an idle close or forge exiting also ends it. [terminal] idle_minutes closes one nobody is attached to, no hand-off is waiting on, no client holds the keyboard of, and nothing has called after that long, and the strip’s row goes back to off. A raised surface belongs to the seat it was opened on: switching seats lowers it, and the old terminal keeps running.

The agent drives it like a human. There is no agent mode, no wrapper and no environment flag of its own: the agent types bytes and named keys through terminal__write, runs a command through terminal__run, reads the screen through terminal__read, and waits with terminal__wait (exact through the shell’s OSC 133 marks, or by the quiet-screen heuristic, which the answer labels state.source: heuristic). The person’s own input is the hand-off’s or their own take control: while either holds the terminal the tools are refused, and a hand-off hands the agent the ending rather than the screen. The keystrokes themselves go from the client to the server to the PTY master, not into the agent’s turn; the shell may echo them, so a read after the hold resolves can carry what was typed.

What it draws

RegionShowsRead from
The hand-off’s cardterminal hand-off · terminal N, the agent’s reason, Open terminal, Done and Not now before the surface is up, and typing is hidden: the program is reading a secret when the PTY has ECHO off. Once the surface is live the card leaves the actions to its barthe terminal_hand_off ask, and the page’s own surface state
The surface’s barthe browser takeover’s own bar: back to chat, terminal N · <shell> · <cwd>, the state line - the agent is running a command, waiting for you, another client has the keyboard, the agent is waiting for input, the shell exited, the terminal closed, you have control, view only, opening the terminal, or the server’s sentence when it failed or refused a take - and, while a terminal hand-off is the front ask, its reason, typing is hidden, Done and Not now; and take control / release control whenever the surface is live, a parked hand-off included (taking control... / releasing... while the server answers, and the server’s refusal in the state line beside the door). No other door: back to chat is the close, and the screen is read through the terminal’s own accessibility treethe terminals state push, the record’s terminal, the page’s own surface state and the parked ask
The surfacethe terminal under the bar, filling the rest: the shell’s screen, its colours and its cursorthe attach’s terminal_view repaint, then the kind-4 output frames
The strip’s rowone pill, like the browser’s: the terminal glyph, the state mark, terminal, the state in words - a command is running, waiting for you, a password is wanted, exited, idle, off - and show / hide, or start on a seat with no terminal. The row is the person’s door even before an agent opens one. On a seat with no working tree - the read refused, a worker never spawned, a slot naming no project - the row is a note, not a door: terminal and the seat has no session yet - start it first, because a terminal opened there would run with no record for the page to drawthe session record’s terminal, the pushed terminal_changed update, and the seat read’s refusal for the no-working-tree state
The touch key barunder pointer: coarse: Ctrl and Alt (sticky), Esc, Tab, Ctrl-C, the four arrows, Enter, /, -, | and ~, each at a 44px targetthe page’s pointer capability
The statesopening the terminal…, the server’s own sentence when the attach failed, and the terminal when it is live. Every state is words as well as colourthe page’s surface state

The rules

The client is a viewer, and xterm.js is the renderer. The attach answers with the visible screen as a VT stream - the server’s grid, its colours and its cursor - and every later frame is the shell’s own bytes, so the client sees exactly what the shell wrote. The attach carries the size the client fitted to, so the repaint comes laid out for the client’s own grid and is drawn at once - a repaint for another grid would have to be skipped and asked for again, which is what read as the screen flushing on a second client. Frames that land while xterm.js is still loading are held rather than dropped, up to a 1 MB bound, past which the surface asks for a fresh repaint instead of drawing a fragment. The DOM renderer is the default; @xterm/addon-webgl is loaded behind a try so a renderer that will not paint never draws an empty terminal over a live shell. Fit, unicode11, search, clipboard (OSC 52) and image (Sixel and iTerm2 IIP) are loaded beside it. Kitty graphics is not drawn; that gap is named rather than hidden.

The keyboard is taken explicitly and lost implicitly. The person presses take control on the bar, and only then do the key bar and the terminal take their keys. One holder at a time across clients - another client’s take overrides it - and the agent’s own call takes it back from the person. Every loss is said: a client that lost the role reads another client has the keyboard (or the agent took the keyboard), so nobody types into a keyboard they no longer hold. Going back to chat gives the keyboard up, and so does the connection ending; a lower is not something a terminal keeps the agent out with. A parked hand-off raises the surface as a viewer: take control there to type, and the ending returns the keyboard to the agent. Outside the role the raised surface is a viewer: the key bar and the terminal’s own input are inert, and the server refuses a keystroke that arrives anyway, while the output frames keep it live. Inside the role, Ctrl-C, Ctrl-D, Ctrl-Z and Ctrl-L reach the PTY untouched. Cmd+J raises and lowers the surface on the desktop app, on a seat whose read answers, where the page handles it; a browser tab keeps that chord for itself, so there the browser’s Back lowers the surface, as the hardware Back does on the phone, because the surface pushes one history entry. Cmd+W is the window’s on the desktop and the tab’s in a browser; on the phone’s hardware keyboard, where neither takes it, it lowers the surface. It is never sent to the shell. A finger cannot produce Escape, Tab or an arrow, so the touch key bar is required rather than a fallback, every control it offers is 44px, and Ctrl and Alt are sticky keys for the next character the soft keyboard types.

Accessibility is honest about its limit. xterm.js’s screenReaderMode exposes the buffer as an accessibility tree, so a reader who cannot use a canvas terminal still gets its content; the bar carries no second copy of the screen, the same way the browser takeover has none, and both are read through their own surface. The raised surface is a dialog (role="dialog", aria-modal), its state line is a live region, and every state forge draws is text as well as colour. A terminal is a grid whose content programs control, so xterm.js cannot promise WCAG-level semantics for arbitrary curses output; the states forge draws are the part forge promises. The surface is not a Tab trap: Tab inside the terminal is the PTY’s key, so a trap could not both leave it to the shell and confine focus, and the bar’s controls stay in the document order instead. Contrast is checked where the token set is, for the terminal’s own chrome; a program chooses its own colours, so ls --color output is not a promise.

The theme is the client’s token set. The 16 ANSI colours, the cursor and the selection are tokens in client/src/theme.ts beside the rest, so a second [client] theme palette themes the terminal for free. The component maps those tokens onto xterm’s theme, with a fallback hex for each token a theme does not set and nothing else of its own.

The desktop shell forwards the bytes itself. On the desktop the page does not hold the socket, so kind-4 frames reach it as a client://terminal-frame event carrying the id, the sequence and the bytes base64, and the page writes each frame to xterm as it lands - the frames are the shell’s own bytes, so a frame is a write, not a repaint.

xterm.js is its own chunk. The surface is imported when a terminal is first raised, so a session that never opens one never downloads the renderer. A chunk that will not load lowers the surface instead of leaving it opening: the strip’s row is its door again, and the next show retries.

Contributing

The full contributor guide lives in CONTRIBUTING.md at the repository root. This page is the short version and the pointers.

The gate

just check
just check-feature-configs

cargo fmt --check, the Unicode punctuation gate, the scripts’ own tests, the shell crate’s rustfmt check (client-tauri-fmt), the client’s Prettier check, ESLint, svelte-check and tsc --noEmit and then its vitest run, the shell crate’s clippy and tests (client-tauri-clippy, client-tauri-test), its android warnings check (client-android-warnings), clippy with warnings denied, cargo nextest run --workspace --all-features, cargo test --doc --workspace --all-features, and cargo doc. CI’s set minus its cargo check --release job, the client build’s scripts/check_client_wasm.py step (just check builds no frontend), and its install-and-boot proof (scripts/install.sh, then a boot of the installed binary with no library path, which neither local command runs), plus the android warnings check CI cannot run yet (client-android-warnings; its vendoring refuses non-macOS hosts - #1959, closed as an enhancement and tracked in #2258). CI also runs the workspace clippy on a macOS runner, which lints the cfg(target_os = "macos") arms a Linux run compiles out; just check covers them only when it runs on macOS.

The gate is two commands: just check, and just check-feature-configs, which compiles the configuration install.sh builds. Both green before you open a pull request. just check runs its four independent groups concurrently - the fast checks, the Rust workspace, the client web steps and the shell crate’s steps - capped by FORGE_CHECK_JOBS (default 4; set it lower on a smaller machine). Every group runs to its own first failure, a failing group’s log tail is printed, and the verdict names the first failing step; each run’s per-step logs stay in their own target/check-logs/run.XXXXXX/ directory, printed as logs: ... before the verdict. just check-serial is the same gate one step at a time, for debugging. When sccache is on PATH, the recipes export RUSTC_WRAPPER=sccache so compiled objects are shared across worktrees and branches; without it they build normally.

For a review round, just check-delta <crate>... runs the fast slice on the crates a change touches: their rustfmt check, the Unicode gate, both clippy feature sets and nextest, plus the client’s steps or the shell crate’s clippy and tests when client or client-tauri is named. It does not replace the gate; the final head runs the full two commands.

CI runs each build and test job in ci.yml only for the paths it serves, and one job, ci-required, always runs, depends on every job in that workflow, and fails when a job that ran failed or when a job whose paths changed was skipped. The ruleset requires two checks: that aggregate and build site, the docs workflow’s build, which runs on every pull request.

A fresh worktree has no client/node_modules; each client step installs it first when it is missing or older than package-lock.json, so the first just check there runs whole rather than stopping at the Prettier step.

The shell under client/src-tauri/ is its own workspace root, so the workspace’s cargo steps do not reach it; in CI its clippy and tests are the client-tauri job’s, and its rustfmt check the cargo fmt --check job’s second invocation. just check reaches it through client-tauri-fmt (the shell crate’s own cargo fmt --check), the crate’s own clippy and tests (client-tauri-clippy and client-tauri-test) and client-android-warnings (the crate compiled for aarch64-linux-android with warnings denied, which is what catches desktop-only code going dead on Android); the Unicode punctuation gate and the client’s Prettier step reach it too. just client-tauri-check builds the shipping configuration (and runs the tests) and just client-tauri-bundle adds the bundles. just client-android-check compiles the Kotlin half and runs its unit tests, because nothing else reaches that code; it needs the Android SDK and a JDK.

The last line it prints is its verdict, [OK] check: ... or [ERROR] check: <step> failed, or [ERROR] check: group reported nothing (<group>) when a group died without a report. A failing group stops at its own first failure; the other groups still run; every failing group’s log tail is printed before the verdict; and the verdict names the first failing step and the later steps in that group that did not run. Read that line rather than a pipeline’s exit status: piping just check through tail reports tail’s status, not the recipe’s.

The book’s own gate

docs/book/ui-word-count.sh measures every docs/book/src/ui/client/*.md page against its recorded prose count in docs/book/ui-word-count.baseline, ignoring mockups and collapsed blocks. A page may shrink freely; a page over its baseline, missing from the baseline, or a baseline entry whose page is gone fails the gate. Growing, adding or removing a page is a deliberate baseline edit in the same pull request, and --record rewrites the baseline from the current pages. just ui-word-count runs the gate, and just check runs that step.

The rules that bite first

  • Clippy runs at pedantic, denied. unwrap, expect, panic, exit, todo and unimplemented are denied, and unsafe_code is forbidden workspace-wide. The client shell crate, a workspace root of its own, mirrors the table and denies unsafe_code with scoped allows in the three FFI modules. clippy.toml relaxes unwrap, expect and panic in tests; the other three stay denied everywhere.
  • No mod.rs. foo.rs sits next to foo/.
  • WARN and ERROR are for forge’s own problems. A session’s own work, its tool call failing or its command exiting non-zero, is information about that session and belongs at debug. A line that does claim a problem names the session, the org, the model, the account or the path, whichever apply, and carries an event_name.
  • No em-dashes, en-dashes, horizontal bars or curly quotes across the scanned source, docs and config file types. CI rejects them. Ellipsis is allowed. just unicode-punct-check shows what would be flagged.
  • New wire surface ships with a captured baseline. See the wire contract.
  • UI changes update the UI surface pages in the same pull request.
  • Changes that make a page here false update it in the same pull request. This site deploys on every push to main, so a page left behind is republished wrong within minutes of the merge.

Where does my code go?

The architecture page has the placement guide. Bias toward the deeper crate when unsure.

Commits and pull requests

Short imperative subjects saying what the commit does. One logical unit per commit. Version bumps in their own commit. Pull request descriptions are prose, as short as the change allows, linking the issue they close.

The wire contract

The claude CLI is the source of truth. forge spawns it and speaks stream-json to it over stdio. forge never re-implements the agent loop and never calls the Anthropic API directly.

That leaves exactly one hard compatibility requirement: what forge writes to the CLI’s stdin has to be what claude expects, and what forge decodes from its stdout has to cover what claude actually emits. A difference either way is a bug in forge, not a feature gap.

There is no compatibility requirement in the other direction. forge-sdk is not a port of the Python claude-agent-sdk and carries no public-API parity contract with it; the two are peer clients of the same binary.

The conformance harness

forge-test-harness is how the wire requirement is enforced rather than asserted. Each scenario runs in one of two modes.

Replay mode is the default and runs on every just check. It loads a committed baseline trace and feeds every inbound line through forge-sdk’s decode_dispatch. It costs nothing and hits no API. The test fails if any line produces a decode error, decodes to an unknown message type, or decodes to an unknown control-request subtype.

Live-capture mode runs only when you ask for it, with FORGE_WIRE_CAPTURE=1 and --run-ignored only. It spawns the real claude, drives forge-sdk through the scenario, and writes the full stdin and stdout trace to target/wire-traces/. It burns real API tokens, so it is not part of any automated run.

just conformance                                  # replay everything, offline
just conformance-capture-sdk wire_capture_trivial_prompt   # one live capture

The argument is a nextest test name, not a baseline name, and the two namespaces do not always match. It is matched as a substring, so a loose argument selects several live captures and bills for all of them. An empty argument is rejected outright rather than passed through, because with no filter left it would capture everything against the real API.

Baselines

Committed baselines live at:

crates/forge-test-harness/baselines/sdk/<PINNED_CLI_VERSION>/<scenario>.jsonl

PINNED_CLI_VERSION is a constant in the harness. The directory name is that constant, so bumping the pin means re-capturing the baselines under a new directory. Between the bump and the re-capture, replay is expected to fail.

Each line is a JSON object recording a direction and a raw wire line. Traces are redacted on the way out, at serialisation, so both the live-capture write and baseline promotion go through the same redaction point.

Baselines have to be live-captured

A baseline is a recording of the bytes that actually crossed the pipe. It is not derived from anything else and cannot be hand-written from a schema, because the point of the artifact is to be evidence of what the CLI really sent, not of what we believe it sends. A synthesised baseline would replay cleanly against a decoder that is wrong in the same way the synthesis was.

The CLI’s own on-disk session files are not a substitute either. They are a different format from the wire: the harness has to run them through a transformer to get them into wire shape before the decoder will accept them. There is a separate opt-in probe that does exactly that, pointed at a directory of real sessions with FORGE_REAL_SESSIONS, so decoder regressions can be caught against accumulated real-world data without committing any of it.

What replay does and does not check

Replay reads inbound lines. It proves forge can decode everything the CLI sent, which is the half that regresses silently. It does not generally assert the bytes forge puts on the wire; the one outbound check is on the in-process MCP initialize handshake, where the test re-runs the current server code against the recorded request and compares that live answer to the recorded response, rather than just checking the two recorded lines agree with each other.

Shipping new wire surface

If your change adds a control-request subtype, a message type, a hook event or a tool integration, it needs three things in the same pull request:

  1. A live-capture scenario that exercises it.
  2. The captured baseline committed under the pinned CLI version’s directory.
  3. A clean replay: every inbound line round-trips through the decoder with no unknown variants and no decode errors.

.claude/skills/claude-cli-upgrade/ in the repository holds the CLI version-bump ritual: the capture command, the baseline layout, and how to add a scenario.

The socket’s own contract

The CLI wire is not the only one forge records. A client reads a second wire, the socket forge-server serves, and it has the same failure mode one layer up: the server renames a field, every test passes, and the page draws blank because nothing compares the two ends.

Those shapes are recorded under:

crates/forge-test-harness/baselines/socket/<PROTOCOL_VERSION>/{frames,chat}.json

They live under baselines/ beside the CLI captures and are not captures themselves: nothing here is recorded live. Both are derived from the current code, which is why regenerating one is a deliberate act.

Only the current version is read. A bump files the new record beside the old one and leaves that one where it is: the directory is an archive of what a server at that version emitted, and its staleness is the point - it is what a reader consults when a client reports the older wire. So nothing notices when a past version’s record drifts, because nothing reads it, and that is deliberate rather than a gap to close.

frames.json carries every variant of the five enums whose tags the socket itself chooses - ServerMessage, SessionUpdate, Subject, Command and ClientMessage - with the wire name each encodes as. The census behind it is one list per enum expanded into both a match with no wildcard arm and the names the record is built from, so a variant the server starts or stops sending is a compile error before it is ever a failing test, and there is no second list to drift. The client writes a ClientMessage tag by hand, so a rename there is a message the server refuses rather than a page that draws blank.

A Message also crosses, inside chat_appended, and its variant tags are not pinned here: they are the CLI’s words rather than forge’s, and what chat.json records of a message is the keys of the fields its variants carry.

chat.json carries the key set of the Message payload the chat fold reads, walked off the committed SDK baselines rather than derived from a schema. That is the payload chat_appended carries, and it is where a renamed field stops matching a name a page reads. frames.json also carries the path-and-key shape of the sampled frames - the chat_appended update and a page with one turn in it - so the paging fields a client reads are pinned rather than declared.

Keys and paths only, never a value. A key that is not an identifier is read as a map key and collapsed to its value’s shape, because a model name or a question’s own text would otherwise move the record whenever the content moved.

Both records are rebuilt from the current code on every just check and compared with the committed copy. Regenerate one deliberately:

just conformance-record-socket

Read the diff against the client before committing it. A field that moved is a page that draws blank, so the question the diff answers is which client read follows it. Nothing reads the client for you: the records say what the server emits, and whether a page reads those names is a person comparing the two against client/src/. The check is on the record, not on the agreement, and a change that renames a field without touching the page is caught here while a page that reads the wrong name is not.

Some limits print themselves on every run. The three subject fixtures report what they actually pin: how many keys they carry, how many are null (the key exists, nothing behind it is pinned), and how many collections they carry empty, where an element shape is pinned nowhere at all and a field renamed inside one is invisible to every pin in the tree.

Others are declared rather than printed. A payload is pinned only for the frames sampled; the fields inside a variant nobody samples are not pinned at all. A container’s rename_all and any variant-level #[serde(rename)] are covered for every variant, because the names are read out of serde rather than derived from the Rust ones - but that reads serde’s DESERIALIZE side, and a client reads what serde WRITES. A split renaming, which names a variant one way out and another way in, is therefore invisible to the names here; the test asserts by source that no such renaming exists in the two files carrying these enums, and in the one carrying the chat payload.

That last check depends on serde’s own unknown-variant message, which it formats rather than contracts: the parse finds nothing if the wording changes, and the emptiness assertion fails loudly rather than the comparison passing against an empty set.