apex · updated October 11, 2026 · 159904621b04

The I/O Plane and Pages

Relevant source files

A page is a window whose body the client draws as a web document instead of text or a terminal grid. Preview's rendered Markdown, apex diff's side-by-side view, the agent tool's transcripts and a web browser on a URL are all pages. A page is session state like any other window. What it shows, where it has navigated to, how many times it has been reloaded and (for some pages) how far it is scrolled all live in the replicated log, so every client that attaches draws the same page. The pixels are a native WKWebView that each UI client places over the window's body rectangle.

Pages need things the log is not built to carry: file bytes, file watches, HTTP fetches and raw TCP tunnels. The I/O plane carries them. It is a second set of numbered, HTTP-shaped streams multiplexed over the same session socket as the log, and it is scoped to the connection that opens them. This page covers both halves, and the client's scrollbar and outline for a page. For the log side of the wire, see The Attach Protocol. For how page state changes reach a leader, see Proposals. For the tools that write pages, see Preview, Web and Diff Tools.

Two planes on one socket

WEB.md sets out the rule that the two planes never mix. The log plane carries entries, leases and proposals, and everything on it is replicated. The I/O plane carries Io { stream, frame } messages in both directions (proto.rs:204-207). They are never logged, they belong to the connection that opened them, and they end when that connection ends. A fenced UI keeps its streams until its connection closes (WEB.md:114-117). The plane is "HTTP-shaped and nothing else": reading a file, watching it, fetching a URL and opening a tunnel are all requests. The frames are postcard-encoded like every other message. HTTP/1.1 text never appears on the wire.

Diagram

Sources: WEB.md:21-36, WEB.md:59-160, crates/apex-server/src/proto.rs:204-246

Frames and streams

IoFrame has five variants, and both directions use the same type (proto.rs:221-227):

pub enum IoFrame {
    Request { method: String, url: String, headers: Vec<(String, String)> },
    Response { status: u16, headers: Vec<(String, String)> },
    Body(Vec<u8>),
    End,
    Reset { reason: String },
}

A client opens a stream by sending Request on an id it picks itself. IoIds hands out odd, increasing numbers, starting at 1 and stepping by 2 (plane.rs:17-29). The server answers with Response, any number of Body frames, and End. Either side may send Reset to abort. A client never sends Response, and the daemon ignores one if it arrives.

On a watch stream, each Body holds a postcard-encoded FileFrame { version, path, bytes }. The version starts at 1 and goes up by one on each change (proto.rs:229-246).

The daemon tracks each connection's open streams in Conn::streams: HashMap<u32, IoStream>. One enum says what each stream is:

IoStream Opened by Held state
Watch { path, version } GET file://… with header Watch: 1 the path and the stream's own version counter
Put { path, body } PUT file://… the body gathered so far; the file is written on End
Tunnel { sock, pending, headed } CONNECT host:port the socket once it connects, bytes that arrived before then, and whether the 200 has been sent
Fetch { method, url, headers, body, started, headed } http:// or https:// the request; the body is gathered until End
Relay { conn, stream } tool://NAME/… the other half of the relay, on the tool's connection

Sources: crates/apex-server/src/proto.rs:214-276, crates/apex-server/src/daemon.rs:136-174, crates/apex-server/src/plane.rs:17-54

What the daemon answers

Daemon::io receives every client frame. It first checks whether the stream is half of a relay. If so, it forwards the frame unchanged to the other half and tears both halves down on a Reset, or on an End from the tool's side. Otherwise it dispatches on the frame type (daemon.rs:984-1050). io_request picks the handler from the method and the URL scheme (daemon.rs:1054-1110):

Results from the worker threads come back to the single state thread as Event::Io(conn, stream, IoUp). io_up then forwards them, but only while the stream is still open. A failure before the response head becomes 502 with the reason as the body. A failure after the head becomes Reset (daemon.rs:1208-1250).

Watches as streaming responses

There is one filesystem subscription per path per session. It is shared with the watcher the server already runs for open files, and the open streams act as its reference count. When the watcher reports a change, file_changed reads the file once and sends a new FileFrame to every Watch stream on that path in every connection of the session. Each stream bumps its own version first (daemon.rs:1310-1335). When a watch stream ends, whether by End, Reset or the connection going, unwatch_unused drops the subscription if no other stream still wants the path (daemon.rs:1337-1354). This design replaced the older ReadFile, Watch and Unwatch messages along with the per-connection watched sets (WEB.md:146-153).

Tool-served requests and the relay

A page via a tool loads tool://NAME/path. io_relay looks for a connection in the session whose attachment is named NAME. If it finds one, relay_to opens a stream on that connection, numbered from RELAYED = 0x8000_0000 upward so that it cannot collide with the odd ids the tool picks for itself. It records each half as the other's Relay and forwards the Request (daemon.rs:1254-1283).

If no such tool is attached but a rule whose action is Tool(NAME) has a start command, the request is held as Held::Stream and the command is run. When the tool says hello, the held request is relayed to it (daemon.rs:631). If the tool does not attach within START_WAIT (10 s), the request gets 503 (daemon.rs:1422). With no rule to start the tool, it gets 503 straight away. See Plumbing for start.

On the tool's side, apex-tool treats incoming streams at or above 0x8000_0000 as requests and leaves lower ones for other users of the link. It gathers each request's body until End and then emits Event::Request(Served). The tool answers with Tool::respond(req, status, headers, body), which sends Response, the body in 256 KiB chunks, and End (apex-tool/src/lib.rs:507-536, lib.rs:580-592).

Sources: crates/apex-server/src/daemon.rs:984-1354, crates/apex-server/src/daemon.rs:1393-1425, crates/apex-server/src/daemon.rs:1637-1643, crates/apex-tool/src/lib.rs:507-592

The plane from a client's side

A program has two ways to use the plane.

Pumped by the owner. Remote exposes io_open, io_send, io_end, io_take, io_response, io_collect and io_next_file, plus the helpers read_file and watch. These read frames from link.io as the owner steps the link. The CLI's apex io is built this way (remote.rs:804-925).

From threads. IoPlane (in plane.rs) is a cloneable handle made of the link's Outbound sender, the shared IoIds, and an IoSinks registry that maps a stream to an mpsc Sender. IoPlane::open registers a sink before it sends the request. The link's reader thread (spawn_reader) offers every ServerMsg::Io to the sinks first, so frames for a stream that a thread is waiting on go straight to that thread and never reach the UI's queue (remote.rs:1056-1082). IoPlane::fetch performs one whole request with a timeout. It sends End after a body, or with no body for http* and tool:// URLs. For a plain GET file:// the daemon needs no End. The result is (status, headers, body), or an error such as "timed out waiting for the host" (plane.rs:56-138). reset sends Reset and drops the sink; close only drops the sink.

apex io [-watch] METHOD URL drives the plane from a shell: GET and PUT on files, a watch, GET on a URL fetched by the host, and CONNECT HOST:PORT acting as nc (apex-cli/src/main.rs:440, implemented by io_cmd at main.rs:2067). These integration tests exercise the plane: a_watched_file_streams_its_changes_until_unwatched (cli.rs:397), tunnels_and_fetches_go_through_the_host (cli.rs:700) and a_web_views_proxy_and_files_ride_the_plane (cli.rs:834).

Sources: crates/apex-server/src/plane.rs:56-138, crates/apex-server/src/remote.rs:804-925, crates/apex-server/src/remote.rs:1056-1140, crates/apex-cli/src/main.rs:440-441, crates/apex-cli/src/main.rs:2066-2067

The Page kind in the core

A window's body is one of Text, Term or Page(Source) (entry.rs:49-56). A page's state is split between the body and fields that every Window carries:

Piece Where Meaning
Source::Buffer(id) Body::Page The document is a buffer's HTML. It is written with ordinary edits, replicated, and patched in place on change. Used by Preview, diffs and the agent's pages.
Source::Url Body::Page The document is at Window::path.
Via::Host (the default) Window::via The document and its resources are fetched through the session's host: its network, and its files as file:///….
Via::Client Window::via Fetched by the machine showing the window. Only the user makes these, never a tool.
Via::Tool(name) Window::via Served by that tool over the plane (tool://name/…).
base Window::base Where relative addresses resolve. If empty for a buffer page, the folder of the buffer's name is used.
reload Window::reload A counter. Each client loads the page again when it moves.
scroll Window::scroll Buffer pages only: Scroll::Line(n) (the last data-line marker at or before source line n) or Scroll::Fraction(f).

WindowOp::Create carries via and base alongside the body and path, and starts reload at 0 and scroll at none. Three window ops change a page afterwards. Path records a navigation, Reload bumps the counter, and PageScroll sets the scroll. State::apply handles them in state.rs:671-673. State::hash includes via, base, reload and scroll, so replicas that diverge on page state are detected (state.rs:956-957).

On the leader, Node::open_page turns a NewPage { content, via, base, label } into entries. For NewContent::Html, it creates a scratch buffer of WinKind::Page (the base defaults to the folder of the buffer's name), then the window. For NewContent::Url, it creates a window whose path is the URL. reload_page refuses a window that is not a page. scroll_page refuses anything but a buffer page, because a URL page scrolls on the client, and it appends nothing if the scroll value is unchanged. web_navigate accepts only Source::Url pages and appends WindowOp::Path when the URL differs. A page's link history deliberately stays off the session's navigation stack: "its history is its tool's" (node.rs:620-743). Other attachments reach these through Proposal::OpenPage, Navigate, Reload and PageScroll (proposal.rs:45-55, proposal.rs:222-240). The SDK wraps them as Tool::new_page, new_web_page, navigate and reload (apex-tool/src/lib.rs:699-755).

Diagram

Sources: crates/apex-core/src/entry.rs:45-113, crates/apex-core/src/entry.rs:192-208, crates/apex-core/src/state.rs:662-673, crates/apex-core/src/node.rs:620-743, ARCHITECTURE.md:705-751

Drawing pages on the client: web.rs

Webs owns one WebHost per page window. A WebHost wraps a wry::WebView built as a child of gpui's window with build_as_child, which on macOS is a WKWebView. Webs also owns the shared event channel, the session's IoPlane, and the port of the CONNECT proxy. Acme::io_plane returns a plane only when the backend is a remote link. An in-process Backend::Local has none, and its views use their own network and read files from disk directly (app.rs:4417-4424).

Placement every frame

The body element calls Acme::web_place(w, bounds, window) each time it is laid out (app.rs:4329-4383). It creates Webs with a plane the first time a view is wanted. Overlays no longer hide the view: it is always placed visible, and holes are cut where overlays are (see below). web_place then branches on the body:

After layout, Webs::settle hides views for windows the layout does not draw and drops views for windows that are gone. A view that had the keyboard hands it back to gpui first (web.rs:1348-1353).

What a view is built with

build installs a set of initialization scripts and handlers (web.rs:1166-1346):

Piece Purpose
CURSOR_SCRIPT Reports the CSS cursor under the pointer (cursor:) and button presses (down:). WebKit's own cursor never reaches the screen inside this window. A down: clears the toasts.
KEEP_SCRIPT Keeps the page's selection painted while the keyboard is elsewhere (__apexAway/__apexBack).
SCROLL_SCRIPT Hides the page's own scrollbar with a constructed stylesheet and reports scroll:top,height,view once per frame when it changes, for acme's scrollbar drawn over the view.
NAV_SCRIPT On a page a tool owns, intercepts plain left-clicks on links in the main document and posts nav:URL instead of following them.
BRIDGE_SCRIPT Defines window.apex.send and apex.onmessage, except in an http: or https: document.
FILE_SCRIPT, ANCHOR_SCRIPT, COPY_SCRIPT, MERMAID_SCRIPT, TOC_SCRIPT Installed for buffer pages only: file links made by scripts, anchors, copy handles on code blocks, Mermaid on request (mermaid:), and the outline (toc:, see below).
Page-load handler Loading(started) events. For URL pages it also reports Navigated(apex_url(url)) from the main frame's URL, ignoring about: pages.
Navigation handler In buffer pages, a link to a host file becomes Link (its owner's to say), and every other link becomes Link too, except about:blank. In any page, a file link with a line, or to a file the view does not show itself, becomes Open(path, line). In URL pages, file:// links become Reroute to apexfile://, bare-loopback links become Reroute through the alias, and about: steps pass. Everything else proceeds.
Proxy config For any page not fetched on the client's own network (Via::Client), wry::ProxyConfig::Http pointing at the local CONNECT proxy.
apexfile and apextool protocols Asynchronous custom-scheme handlers, each request served on its own thread by Fetcher.

The IPC handler turns the page's window.ipc.postMessage strings into WebEvents by prefix: down:, mermaid:, scroll:, toc:, cursor:, copy:, msg: and nav: (web.rs:1195-1240).

URL forms

The session and WebKit spell some URLs differently. webkit_url and apex_url convert between the two forms, and the session only ever sees its own (web.rs:2135-2163):

Session form View form Why
tool://NAME/p apextool://NAME/p WKWebView can only intercept custom schemes.
apexfile:///p apexfile://localhost/p WebKit dispatches a custom scheme only when the URL has a host.
http://127.0.0.1:N/… http://127-0-0-1.apex-host:N/… A web view never sends a loopback name through a proxy, so the host's loopback travels under an alias that the proxy undoes.
http://localhost/… http://localhost.apex-host/… Same reason.

Sources: crates/apex-client/src/web.rs:1-66, crates/apex-client/src/web.rs:1120-1346, crates/apex-client/src/web.rs:1968-2163, crates/apex-client/src/app.rs:4329-4424, crates/apex-server/src/plane.rs:140-196

Network through the host: the CONNECT proxy

WKWebView cannot intercept http or https with a scheme handler, but on macOS 14 and later it accepts a proxy configuration per data store (WEB.md:353-369). start_connect_proxy(plane) binds 127.0.0.1:0 and accepts connections, each handled on its own thread by proxy_one (plane.rs:198-296). For each connection, proxy_one:

  1. Reads the request head byte by byte up to the blank line, with a 64 KiB cap. Anything other than CONNECT gets 405.
  2. Undoes the loopback alias in the target host (unalias_host).
  3. Opens a CONNECT stream on the plane and waits up to 30 s for the answer. A non-200 answer becomes 502 Bad Gateway, with the plane's status in X-Apex-Status. No answer becomes 504.
  4. Replies 200 Connection Established. It then pumps the socket into the stream on one thread, sending End when the socket closes, and pumps the stream's Body frames into the socket until End or Reset.

TLS runs between the page and the origin, end to end through the tunnel. The page therefore sees the host's network: on a remote session, an internal dashboard on the remote machine loads as it would there. The alias has known gaps. Absolute loopback URLs in a page's own resources still go to the client's loopback, and the page's server sees the alias in its Host header (WEB.md:334-351). APEX_WEB_DEBUG in the environment makes the proxy and the views log what they do.

Diagram

Sources: crates/apex-server/src/plane.rs:140-296, crates/apex-client/src/web.rs:1315-1320, WEB.md:332-369

apexfile:// and apextool://

Fetcher::serve answers apexfile:// requests (web.rs:1668-1705). It serves the bundled fonts first, from the client itself (fonts::serve). Otherwise it strips localhost (and the alias, if present), decodes the path with file_url_path, and fetches it with plane.fetch("GET", file_url(path), …) and a 30 s timeout, or reads the disk when there is no plane. A failed fetch becomes 502. The content type comes from mime_for on the extension (plane.rs:333), and non-200 answers are text/plain.

After a successful fetch, Fetcher::watch opens a Watch: 1 stream on the path. There is at most one such stream per path per page, and at most 200 per page. The first frame is skipped because it holds the file the page already has. Each later FileFrame sends WebEvent::Reload. The watches end when the WebHost is dropped (web.rs:1733-1767, web.rs:482-492). Editing an image or stylesheet on the host therefore reloads the page that uses it. For a buffer page, a reload marks its version stale (u64::MAX), and the next placement morphs it (or reloads it, if it has scripts) (web.rs:1625-1634).

Fetcher::serve_tool turns apextool://NAME/… into tool://NAME/… and passes the method and body through with plane.fetch. It takes the content type from the tool's Content-Type header, or else from the extension. An error is 502. With no plane it answers 503, because an in-process session has no tools to ask (web.rs:1710-1729).

plane::host_file is the client's reading of a link to a host file (file:// or apexfile://, with a line from ?line=N, #LN or a compiler-style :N[:M]). Both the navigation handler and follow_link use it (plane.rs:298-330).

Sources: crates/apex-client/src/web.rs:1655-1767, crates/apex-server/src/plane.rs:298-340

Page events, owners and the script bridge

Views report into Webs' channel. Acme::web_events drains it each sync and acts on each event (app.rs:4470-4533):

page_owned(w) holds only for a remote, unfenced client showing a page owned by an attachment other than its own. tell_owner checks it before sending anything, which is how "only the leading client reports what happens in a page" is enforced (app.rs:4557-4581).

The bridge also runs the other way. A tool calls Tool::post_to_page(w, json), which sends ClientMsg::PostToPage. The daemon accepts it only from the window's owner and forwards it to the session's leader, which queues it in link.page_posts. page_answers hands it to Webs::post, and that calls window.apex.onmessage(json) in the page. Posted messages are never logged (daemon.rs:792-800, web.rs:1544). A tool opts into navigation questions and page events with Tool::handle_pages, and then hears Event::Navigate (answered with answer_navigation) and Event::Page (apex-tool/src/lib.rs:537-545, lib.rs:594-616). The wire types are Request::Navigate, NavAnswer (Allow, Redirect, Handled, Default) and WindowEvent (Navigated, Loading, Title, Message) (proto.rs:407-449).

Diagram

Sources: crates/apex-client/src/app.rs:4470-4627, crates/apex-server/src/daemon.rs:768-809, crates/apex-server/src/proto.rs:132-143, crates/apex-server/src/proto.rs:407-449, crates/apex-tool/src/lib.rs:537-616, ARCHITECTURE.md:753-773

The page scrollbar and outline

The page's own scrollbar is hidden by SCROLL_SCRIPT, and acme's scrollbar is drawn over the view's right edge as it is over a text. In the body element (main.rs:445-484) a zero-paint canvas records the bar's bounds in Acme::web_bars (app.rs:665), works out the thumb from the page's reported scroll (Webs::thumb), and hands Webs::set_bar a BarLook: the strip's width, an optional gutter, the thumb, and the outline's ticks. The lane counts as open when the pointer has opened it (lane_open) or when the outline is out for this window (acme.outline.open == Some(w)); an open lane widens the pass-through strip and shows a gutter and a stronger thumb. On macOS set_bar draws that as a CALayer inside the page's own layer and rebuilds it only when the look changes (web.rs:960-1044). The bar's strip is registered as a pass-through region for the hit-test override (bar_pass), so the pointer in the strip belongs to apex rather than to the page (web.rs:833-838). locate maps a pointer in web_bars to Region::WebScrollbar, and the usual acme buttons apply: B1 and B3 scroll by the distance to the pointer, B2 jumps to a fraction (app.rs:3157-3158, app.rs:4869).

Where the outline comes from

outline.rs adds the page's headings to that bar. The headings come from TOC_SCRIPT, which is installed in buffer pages only. It collects every h1 to h4 that has text and posts them as toc: lines: first the page's scroll height, then one line per heading holding its level, its top in CSS pixels, a p if it sits in a section.file (a diff's file, whose .name element gives the label), and its title. It re-posts, at most once a frame, on resize, load and any DOM mutation, and only when the text changed (web.rs:287-332). The client parses this into a Toc { height, heads: Vec<TocHead { level, title, top, path }> } (web.rs:500-553):

Toc method What it answers
at(i, scroll) Heading i's position down the page, 0 to 1. The page counts as long as the larger of the outline's height and the latest scroll report, since it may have grown.
current(scroll) The heading the view is in: the last one at or above a quarter of the way down the view, or the last of all once the page is scrolled to its foot.

Webs::toc(w) returns a page's outline only if it has two or more headings, with the current heading. Webs::toc_go(w, i) calls window.__apexTocGo(i) in the page, which scrolls smoothly to the heading, 12 pixels short of it (web.rs:1481-1501).

Ticks and the pointer-revealed list

outline::ticks turns the outline into the bar's ticks: one per heading, placed at toc.at(i), as long as its level says (TICK = [0, 9, 7, 5, 3] points for levels 0 to 4, so h1 reaches furthest), the current heading in the accent colour and the others in dim text at alpha 0.4, or 0.75 while the lane is open (outline.rs:26-56).

The outline panel appears when the pointer enters the bar of a page that has an outline. Acme::outline_hover (outline.rs:78) is called from the pointer-move path (app.rs:3666-3667) and finds the bar under the pointer in web_bars. It marks the heading whose tick is nearest the pointer's height (nearest) and positions the panel level with that heading's row, clamped to the bar and scrolling the list if the panel cannot go higher. When the pointer moves onto the panel itself (within 4 points), the panel stays put so a heading can be chosen; elsewhere it closes. The state lives in Acme::outline: Outline { open, bar, top, near, panel_at, scroll } (app.rs:678-679, outline.rs:29-41). outline_panel (outline.rs:112) draws it, anchored at the bar's left, as a deferred overlay added to the root (main.rs:655): one 23-point row per heading, indented 12 points per level, h1 and the current heading semibold, the current one in the accent, the nearest washed. A path title longer than 44 characters is cut from the front to its last 43, since the end of a path says most. B1 on a row calls toc_go (outline.rs:149). The panel records its bounds in panel_at, which is also how it is treated as an overlay over the native view (see the holes below).

Sources: crates/apex-client/src/outline.rs:1-188, crates/apex-client/src/web.rs:287-332, crates/apex-client/src/web.rs:439-553, crates/apex-client/src/web.rs:1476-1501, crates/apex-client/src/main.rs:445-484, crates/apex-client/src/app.rs:3657-3667

Back, Fwd, Get and the web header

A URL page has no tag text. webbar.rs draws a header in the tag's place instead (webbar.rs:87). It holds the window's handle, which moves, resizes and minimizes the window like any other (Acme::press_handle, with B4 minimizing), plus back and forward buttons and an address pill. Clicking the pill starts a UrlEdit on a LineEdit pre-filled with the window path. Enter passes the typed text through address() and calls node.web_navigate. address() keeps any text with a scheme, about: or data: as it is. Text starting with / or ~ becomes file://…. Text starting with localhost or 127.0.0.1 becomes http://…, and anything else becomes https://… (webbar.rs:33-84). While an address is being typed (or any other overlay is up: menu, finder, selector, command palette, switcher, overview, tag or session editing, pickers, quick open), overlay_up is true, which keeps the keyboard away from the pages (app.rs:4426-4431).

Acme::page_nav handles the buttons and the tag words Back, Fwd and Get. On a window a tool owns, it proposes Exec of the word in the window, so the tool, which keeps the page's history, answers. On any other page, Get proposes Reload, so every client loads the page again, and Back and Fwd use the view's own WebKit history (Webs::go) (app.rs:4385-4407).

A buffer page can declare words of its own with <meta name="apex-verbs" content="Prev Next">. page_verbs parses them from the first 8 KiB of the HTML, and B2 on one of those words runs window.apexVerb(word) in the page (Webs::verb). apex diff's Prev and Next work this way (web.rs:2165-2181, web.rs:1423).

Sources: crates/apex-client/src/webbar.rs:1-192, crates/apex-client/src/app.rs:4385-4431, crates/apex-client/src/web.rs:2165-2181

Living with a native view

A WKWebView draws above everything gpui paints and keeps pointer events to itself. The client works around this in four ways.

Linux (WebKitGTK) placement and its proxy configuration are open questions. Most of the AppKit-specific code above is compiled out or stubbed off macOS.

Sources: crates/apex-client/src/web.rs:794-1098, WEB.md:38-51, WEB.md:653-661

Design status and what remains

ARCHITECTURE.md §5's note dated October 2026 lists what is built: the Page kind (Body::Page, Source, Via), its state in the log, the request and event framework (Ask/Answer, WindowEvent, PostToPage), tool-served pages, the script bridge, and Preview and Web as tools started when first wanted. Three special cases from before remain. Page verbs are still parsed from HTML (apex diff has no tool to answer them), Look in a page still goes through Node's page_finds queue, and ClientDo is still used for things other than pages (ARCHITECTURE.md:685-696).

Older sections of WEB.md describe Body::Web, Body::Html, OpenWeb, OpenHtml and WebNavigate. Those names are gone; where WEB.md and ARCHITECTURE.md §5 disagree, §5 describes what runs. The review in ARCHITECTURE.md also notes that the daemon's own HTTP fetch overlaps with CONNECT tunnelling, and that folder listings could become GET file:///dir/ on the plane (ARCHITECTURE.md:513-521).

Sources: ARCHITECTURE.md:670-869, WEB.md:11-17

Previous: Program Status Protocol (OSC 7501)Next: Remote Hosts and Providers