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

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.