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
| Crate | What it owns |
|---|---|
forge-primitives | Every 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-capture | Microphone 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-dictate | The 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-tts | The 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-one | The 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-terminal | One 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-supervise | Supervision 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-gateway | The 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-connectors | One 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-sdk | The claude subprocess. Stream-json codec, transport, control dispatch, the in-process MCP host, and the options builder. |
forge-agent | Drives 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-workspace | The 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-browser | The 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-server | The 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-harness | The 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.
- 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 inforge-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 inforge-tts, a leaf besideforge-dictate: the two directions have disjoint model families and dependencies, and a leaf holding both would be one crate with two unrelated engines. - System One decision-model I/O (the
[systemone]config shape, the wire question and answer types with their validation, the HTTP call) goes inforge-system-one. A leaf: it may not depend on any forge-* crate, and its own types stay there even once another crate reads them. - 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. - 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. - 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. - 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 theGotifyHostport that forge-workspace implements, Slack holds only its Web API client, and the vault module carries no session or keys. - 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. - 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. - 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 theBrowserHostport. A frame’s wire shape is aforge-primitivestype; a viewer’s drawing is the client’s. - Orchestration across projects, sessions, accounts,
forge.tomlor the command bus goes inforge-workspace. A read a VIEW needs is a verb on the view surface below, not a bare method. - 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. - 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 inforge-serverrather than against the core, and a read of the core goes through the view surface inforge-server, never throughforge-workspace. - A wire-conformance scenario goes in
forge-test-harness. - 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
SessionUpdatevariant 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 onProviderin workspace or a view is the thingforge-gatewayexists 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-serverand 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.tomlstays 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.