2026-08-01 · vita
Contents

scry build guide

Audience: an agent. This is a build spec, not a tutorial. It describes how to build a native desktop browser whose chrome is real GUI code and whose web content is Chromium rendered offscreen into a texture — and, more importantly, it records every trap the reference implementation hit so you don't pay for them twice. Every number here was measured on one Linux box (32 cores, AMD RADV, Hyprland/Wayland with an Xwayland fallback) during a single overnight build.

The reference implementation is called scry. It is Linux-only, single-user, ~156 tests, built in six OODA rounds overnight plus a morning of tweaks. Where a detail is specific to the reference system's data sources, the guide names the general pattern first and the concrete instance second.


0. what you are building, and the one bet that makes it worth building

A browser where everything outside the page rectangle is native: tab strip, omnibox, sidebar rail, command palette, context menus, prompts, toasts.

The architectural bet, stated precisely:

An Electron/Tauri shell can only draw inside the webview rectangle. Native popovers, menus painted over web content, sidecar panes, and OS-level chrome that knows about your data all live outside it. If the affordances you want are outside the rectangle, you need the host to be a native UI toolkit and the web content to be a composited texture inside it — not the other way around.

The second bet is about content: you are not building a browser for the open web. Keep Chromium for that. This browser is for pages you control, which buys you a much softer threat model and lets both ends of the connection be yours — pages can call into the shell, and the shell (and your agents) can script pages.

If neither bet applies to you, build an Electron app instead. It is much less work.


1. architecture

                    ┌──────────────────────────────────────────┐
   ctl client ─────►│  app crate       GPUI host               │
  (unix socket)     │    window · tab strip · omnibox · rail    │
                    │    palette · native menus · session.json │
                    └───┬──────────────┬───────────────┬───────┘
                        │              │               │
              texture + │        query │        bridge │ CDP loopback
                 input  │              │               │ (window.<api>)
                    ┌───▼──────────┐ ┌─▼────────────┐  │
                    │  cef crate   │ │ index crate  │  │
                    │  OSR, pump,  │ │ jump index   │  │
                    │  input xlate │ │ N sources    │  │
                    │  ALL unsafe  │ │ fuzzy match  │  │
                    └───┬──────────┘ └─┬────────────┘  │
                        │              │               │
                   CEF 151        YOUR OWN data stores │
                  (prebuilt)                           │
                        │                              │
                    ┌───▼───────────────────────────────┐
                    │  cdp crate  :9223 — agents read   │
                    │  and script every open tab        │
                    └───────────────────────────────────┘

Four crates. The seams are the design:

crate job why separate
*-cef offscreen rendering, message-loop pump, texture hand-off, input translation all unsafe and all CEF types live here. The app crate never sees a raw CEF handle. This is what makes the rest of the app boring, testable code.
*-index the jump index: source adapters over your data, fuzzy query, sensitivity gating headless, no GUI dependency. It builds and tests in ~10 s without touching GPUI. You will iterate on it constantly; do not chain it to a 40 s GUI build.
*-app the binary: window, tabs, compositing, input routing, omnibox, rail, palette, session, control socket the only crate that knows what your product is
*-cdp CLI + library over the DevTools port optional, deletable, experimental

Two more seams inside the app crate, both intentionally amputatable:

Dependency direction is one-way and enforced by policy. If you have a sibling project that shares GUI helpers, depend on it by path and never the reverse; extract into a shared crate only when a second consumer actually appears, never speculatively.


2. dependencies and pinning

GPUI

GPUI is Zed's GUI framework. It is not published to crates.io in a form you can rely on; you pin a git rev and [patch] it to a local checkout:

[workspace.dependencies]
gpui = { git = "https://github.com/zed-industries/zed" }
gpui_platform = { git = "https://github.com/zed-industries/zed", features = [
  "font-kit", "x11", "wayland", "runtime_shaders",
] }

[patch."https://github.com/zed-industries/zed"]
gpui          = { path = "/abs/path/to/zed/crates/gpui" }
gpui_platform = { path = "/abs/path/to/zed/crates/gpui_platform" }
gpui_macros   = { path = "/abs/path/to/zed/crates/gpui_macros" }

Rationale for the [patch] indirection rather than a bare path dep: every project on the box that uses GPUI points at the same local checkout, so they can never drift onto different revs, and if the checkout moves the build fails loudly instead of silently resolving to zed HEAD.

Two more pins that matter:

[profile.dev.package]
gpui = { opt-level = 3 }
gpui_platform = { opt-level = 3 }
taffy = { opt-level = 3 }

Note the API detail: at this rev the app is constructed with gpui_platform::application(), not Application::new().

CEF

Use tauri-apps/cef-rs, crate name cef. This was a real evaluation, not a coin flip:

option verdict
cef (cef-rs, tauri-apps) chosen. The only actively maintained binding — its 151.1.0 release landed the same day as the reference spike, and it tracks upstream CEF within days. Ships cef-dll-sys (generated C-API bindings + a build script that downloads and arranges the matching binary distribution) and a working examples/osr.
cef-ui (hytopiagg) dead — last commit Sept 2024, CEF 1xx era.
wef abandoned; it was the CEF-OSR crate inside longbridge/gpui-component, which has since replaced it with gpui-wry. Useful prior art, not a dependency.
hand-written C shim over libcef unnecessary — cef-dll-sys already is exactly that, generated.
cef = { version = "151.1.0", default-features = false }

Do not enable the accelerated_osr feature: it wants a wgpu::Device that GPUI does not hand out (see §5).


3. the CEF binary distribution — fetch mechanics

You do not vendor Chromium. You need the prebuilt CEF distribution matching the crate's build metadata:

cef 151.1.0+151.3.12  ⇒  cef_binary_151.3.12+gd9cea67+chromium-151.0.7922.47_linux64_minimal

321 MB, from cef-builds.spotifycdn.com.

mkdir -p .cef
export CEF_PATH="$PWD/.cef/cef_binary_151.3.12+gd9cea67+chromium-151.0.7922.47_linux64_minimal"
cargo build --release -p your-app        # downloads + arranges on first build

Mechanics worth knowing before you burn a build:

LD_LIBRARY_PATH is not optional. The arranged directory must be on it at runtime or the dynamic loader cannot find libcef.so. Make a runner script the only supported launch path:

#!/usr/bin/env bash
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
export CEF_PATH="${CEF_PATH:-$HERE/.cef/cef_binary_151.3.12+...+linux64_minimal}"
export CEF_DIR="$CEF_PATH/151.3.12/cef_linux_x86_64"
export LD_LIBRARY_PATH="$CEF_DIR:${LD_LIBRARY_PATH:-}"
export APP_BIN="${APP_BIN:-$HERE/target/${PROFILE:-release}/app}"
exec "$APP_BIN" "$@"

Consequences you will hit: your ctl client lives in the same binary and therefore also needs LD_LIBRARY_PATH, so it goes through the runner too (./run.sh ctl status). A bare ./target/release/app ctl status dies with error while loading shared libraries: libcef.so. And your .desktop entry must Exec the runner, never the binary.

build times (measured, 32 cores, CEF already arranged)

what time
build the *-cef crate, deps warm 9.6 s
build the app crate, debug, after GPUI was built 38 s
build the app crate, release, incremental after a source edit 18 s
fully cold including the 321 MB download (spike measurement, same dep set) ~90 s

The 18 s number is the whole point: CEF is a prebuilt blob outside the build path, so your edit-run loop never recompiles Chromium.


4. process bootstrap and the message pump

subprocess re-exec

CEF re-runs your own binary for the renderer, GPU, zygote and utility processes. Therefore:

execute_process() must be the first statement in main(), and non-browser processes must return immediately — before GPUI initializes anything.

Detect via the type command-line switch. Note the cost: a debug binary can be ~300 MB and gets re-exec'd ~6 times. cef-rs supports a separate helper binary ([package.metadata.cef.bundle] helper_name) so renderer processes don't carry your whole GUI image; worth doing before you ship, not before you demo.

threading — single-threaded, and it is not a fight

This is the main architectural question and it resolves cleanly:

Do not ship the naive fixed-interval poll. A 6 ms timer works and is what the spike used, but external_message_pump is contractually paired with CefBrowserProcessHandler::on_schedule_message_pump_work(delay_ms): CEF tells you exactly when to pump next. Implement it, schedule a one-shot foreground task at that delay (clamp to roughly [0, 16] ms, collapse pending wakeups). This drops idle CPU to near zero — the poll burns cycles on a static page — and removes up to 6 ms of input latency.

Wrap all CEF handles in one host type that is explicitly !Send and panics if touched off-thread, rather than relying on convention.


5. the texture path — the single best discovery

CEF's OnPaint buffer format and GPUI's RenderImage format are identical. Both are BGRA8, premultiplied, top-left origin. GPUI's RenderImage is documented as "a cached and processed image, in BGRA format" even though it is typed as image::RgbaImage. So the bytes go straight across with zero swizzle, zero conversion:

let rgba = RgbaImage::from_raw(w, h, bgra_bytes)?;   // no conversion
let image = Arc::new(RenderImage::new(smallvec![Frame::new(rgba)]));
// render: img(image).size_full()

The trap that will leak your GPU memory: every RenderImage gets a fresh ImageId and GPUI caches it in the sprite atlas by that id. Without dropping the previous one, the atlas grows by a full frame on every paint. Call cx.drop_image(old, Some(window)) when you replace the frame. Sixty times a second, a 4 MB frame, no eviction — you find this within a minute if you are watching, and never if you are not.

Also:

measured performance

Sustained synthetic wheel scroll, 1280×784 logical, windowless_frame_rate: 60:

That is the CEF frame cap, not a ceiling you are hitting. Subjectively indistinguishable from a normal browser at that size, with a small extra latency consistent with a one-frame-plus-pump-interval pipeline.

The CPU path will not scale to 4K/HiDPI — the per-frame copy is O(pixels) and the atlas upload becomes the bottleneck. The zero-copy fix is on_accelerated_paint + SharedTextureHandle → DMA-BUF import (cef-rs already implements the import). It requires GPUI to expose its wgpu::Device or accept an externally-created texture as an ImageSource. That is a fork-or-upstream decision, not a code decision — scope it early or accept the CPU path deliberately. Ship the CPU path first; it is ~30 lines.


6. the gotchas, in the order you will hit them

Each of these cost real time in the reference build.

  1. CEF and your GUI toolkit must agree on Wayland vs X11. CEF picks its ozone platform from the session environment independently of which backend GPUI chose. If they disagree the process aborts at startup (Failed to connect to Wayland displaycef initialize failed). Fix: in on_before_command_line_processing, append --ozone-platform=x11 when WAYLAND_DISPLAY is unset, so CEF follows the toolkit. Choose the two together, deliberately, at one place in the code.

  2. device_scale_factor: logical in, device out. Hardcoding screen_info.device_scale_factor = 1.0 while the window reports 1.1× gives an upscaled, blurry texture. view_rect must stay in logical px while screen_info reports the real scale; CEF then paints at device pixels. Plumb both from window.scale_factor(). Never pre-multiply coordinates by the scale factor in the event structs.

  3. Keyboard needs two events. RAWKEYDOWN alone does nothing visible in a text field. The CHAR event, with character set as a UTF-16 unit, is what inserts text. Enter, backspace and tab also need an explicit CHAR.

  4. native_key_code has no portable source. GPUI's Keystroke does not expose the hardware keycode, so the reference reconstructs it from a US/evdev table. Correct on a US layout; wrong scancode (right logical key) on others. Chromium uses it for layout-sensitive shortcuts. If you care, get the platform keycode properly.

  5. Popup widgets must be blitted inside the CEF seam. OnPaint with PaintElementType != PET_VIEW — native <select> dropdowns, autofill — is a separate buffer that a naive implementation drops on the floor, so <select> silently does nothing. Composite the popup buffer into the main buffer at the rect from OnPopupSize. Do it inside the CEF crate: zero app-side changes, and the app never learns that popups exist.

  6. Set root_cache_path. Unset, CEF warns about process-singleton behaviour on every start. Set it per-profile; it is also what makes logins survive restarts.

  7. Shut CEF down cleanly at exit (close every browser → wait for OnBeforeClosecef::shutdown()). Be honest about why: the real win is an orderly profile flush, not reaping zombies — the kernel already does that. Killing the process leaves the profile at risk, not the process table.

  8. Chrome-only repaints need an explicit refresh. cx.notify() re-runs render, but with a static page producing no CEF frames, the X11 backend will keep presenting the previous frame until the next mouse event. A sidebar toggle changed in state and not on screen. Every chrome-only state change (toggle, status line, toast) must also call cx.refresh_windows(). This class of bug looks exactly like a screenshot artifact and is not one.

  9. Flex children shrink by default. Rows in a scrolling flex column get squished instead of scrolling when the window is short; a sidebar next to a web-content pane collapses to zero width on the frame right after a toggle, because the page texture is still sized for the old box. flex_none on every row and header, and min_w on the pane.

  10. Overlay menus must occlude(). A click on a native context-menu row was delivered to the interactive element behind the menu and navigated the tab. One occlude() on the menu pane fixes it; check every overlay you have, the bug is latent in all of them.

  11. --no-sandbox is required by the prebuilt distribution and it is a real gap, not a shortcut. The shipped chrome-sandbox is not root-owned setuid, so the helper cannot run. Fixing it means chown root:root && chmod 4755 on $CEF_PATH/.../chrome-sandbox at install time, or a userns sandbox. See §11 on whether you are allowed to ship anyway.


7. the jump index — "search over YOUR OWN data", generalized

This is the feature that makes the thing worth using rather than a demo. The omnibox is not a URL bar. It is a fuzzy query over the set of destinations you actually navigate to, assembled from your own systems.

the pattern

  1. Inventory your navigable universe first, on disk, before writing code. Write it down as a document with measured counts, per-source freshness, and sharp edges. The reference spent the first ~30 minutes of the build on this and it shaped every later decision.
  2. One adapter per source, each returning a uniform Entry { title, subtitle, url, kind, sensitivity, ranking_prior }.
  3. Every adapter may fail independently. A failed source is reported by name with its error in the index-rebuild output; it never fails the build and never silently returns zero. ! source.name — FAILED: <error> on the rebuild line is the entire honesty mechanism.
  4. Cost tiers. Local sqlite/disk sources rebuild in tens of milliseconds; network/subprocess sources get a TTL cache (an hour) and a short timeout (a slow localhost is a dead localhost).
  5. Fuzzy match with nucleo-matcher, ranked by score plus a per-entry prior. Sub-millisecond over ~1000 entries, so no debounce: query on every keystroke.
  6. Anything slower than ~50 ms is a separate explicit mode, never keystroke-live. The reference's full-text memory search takes 1.2–3 s warm and 15 s cold, so it lives behind a ? prefix with a visible mode chip, an honest in-flight spinner, and no work at all until you press enter.
  7. Sensitivity is a first-class field, and it gates the UI. Entries carry a sensitivity; anything marked local-only wears a visible chip and refuses share actions rather than hiding the reason. Over-marking is the safe direction — the only cost is a withheld action.
  8. Open the underlying stores READ-ONLY. The index is a reader. All writes go through the owning system's own API (see §8).
  9. Everything is env-overridable (APP_DATA_ROOT, APP_BASE_URL, APP_CACHE_DIR, per-source binaries). This is what lets you run an isolated instance against fixtures — which you will need for tests, for screenshots, and for demoing without exposing your own data.
  10. Cache the built index as JSON, written atomically (temp file in the same dir, then rename), so a killed process cannot leave half an index behind.

the reference's sources, as an example

984 entries, 8 sources, warm rebuild 44 ms, query <1 ms:

    760  reports.db          local sqlite with an FTS index — the big one
     71  spoke sites         registry via a company CLI (network, TTL-cached)
     63  SPA routes          enumerated from route files on disk at build time
     36  plats/canvases      same CLI
     28  links               a personal links store (sqlite), tiered pinned/active/inbox
     12  frequents           frecency over browser history — a free ranking prior
      8  workshop pages      HTTP against a local API (runtime directory scan)
      6  dev servers         a known port list, health-probed

Substitute your own: a notes database, a ticket tracker's local cache, your repos, your bookmarks, your team's runbooks, your dev servers. The properties that make a source good are (a) you navigate to it often, (b) it has a stable URL, and (c) something on your box already knows the list.

Two findings from the reference worth stealing:


8. writes go through the owning system

The index reads sqlite directly. Writes never do. When the palette pins a page, moves a tier, or renames a link, it shells out to the owning system's own CLI/capability layer with an absolute path to that system's runner and its own working directory.

Why this matters more than it looks:

Corollary: never read another system's secret files. The reference mints share links by POSTing the owning API's idempotent …/share endpoint rather than reading its 0600 token file. If a surface has no share scheme, the palette row is greyed with the reason stated rather than guessing a URL. Greying with a reason, everywhere, is the house style: "unavailable — this page is not in links", "LOCAL ONLY — this surface is never shared".


9. the control socket — the dogfood loop, and it is the point

A running instance listens on a Unix socket (~/.local/share/<app>/control.sock, mode 0600). The client lives in the same binary: app ctl <cmd>.

ctl status                    version+sha, pid, uptime, tabs, index, sidebar
ctl tabs                      list open tabs
ctl open <url> [--new-tab] [--background]
ctl navigate <url> [--tab ID]
ctl activate <ID> · ctl close <ID>
ctl reload-index              rebuild, report per-source counts
ctl restart                   lossless restart onto the current build
  --json                      raw reply object instead of the table

Protocol: JSON lines, request in / response out, many pairs per connection. {"reply":"error","message":…} is the failure shape for everything. A bare connect with no line is a liveness probe and gets no response — that is how the server distinguishes a live socket from a stale one at startup.

Exit codes: 0 ok · 2 no server · 3 server error or bad args.

Threading. The socket server runs on its own std thread and never touches the GUI or CEF. It parks each request in an inbox with a reply channel; the app drains that inbox from a GUI task on the main thread every 40 ms, handles the command with &mut App in hand, and sends the reply back. Socket IO therefore never blocks the UI thread, and CEF is only ever touched from the thread that initialized it. Two commands answer late and carry their reply channel with them: reload-index answers after the rebuild lands (so the counts describe the index actually in use), and restart answers before the exec.

how a lossless restart works — and the deleted-inode trap

Measured blip: 0.65 s / 0.69 s, wall clock from issuing restart to the new process answering status, with every tab restored.

  1. Flush the session synchronously. The debounce is not good enough here — that is exactly the lossless promise.
  2. Reply restarting, then wait up to 750 ms for the connection thread to confirm the bytes are out. Exec-replacing before the client reads its reply is indistinguishable from a crash.
  3. Close every browser and pump the message loop for ~300 ms so the child processes let go of the profile.
  4. execv(<the binary this process booted from>, argv + --restarted).

The trap, and it is expensive: use std::env::current_exe() canonicalized at boot, not /proc/self/exe at restart time. Cargo replaces the binary by rename, so by restart time /proc/self/exe resolves to the deleted inode and you silently re-run the build you were trying to replace. In the reference this shipped, and three restarts showed no sign of a new code path that appeared instantly on kill+relaunch. Until it was fixed, every previous round's "restart and check" verification was worthless. Fall back to a $APP_BIN env var from the runner, then /proc/self/exe as a last resort, so a restart can never become a crash — and if execv fails, keep running the old build and say so.

execv, not spawn-and-exit, for three reasons: the pid survives (anything watching it keeps watching), the environment survives (LD_LIBRARY_PATH from the runner is what lets CEF find libcef.so), and there is no window in which two processes contend for the CEF profile lock.

One related trap: do not ctl restart onto a binary you have just rebuilt while CEF children are alive if your rebuild touched the CEF child path. The old process execs the new binary, but CEF's helper launches then fail (failed to execvp: …/app (deleted)) and the devtools port can end up rebound on [::1] instead of 127.0.0.1. Kill and relaunch in that case.

Show a receipt. A restart and a crash-plus-relaunch look identical from the outside; a 3 s toast (restarted · v0.1.0 (94f861aa2+)) and the client's back in 0.69s … line are what make the difference visible.

Deliberately no quit command. An agent should be able to replace your browser, not close it.


10. singleton — one instance, always

Two instances is not a degraded browser, it is a corrupt one: they fight over the session file, they both write through to your data store, and only one can own the debug port. The rule to implement is relaunch wins — launching from the app menu always yields exactly one live, visible instance with your tabs in it.

Run the claim in main after the CEF child-process fork check (so helper re-execs never do it) and before CEF initialize (so the old instance has released the profile, the port and the socket before the new one wants them):

  1. flock an exclusive lock file. Two launchers double-tapped a second apart serialize here.
  2. Read a pidfile (pid\nexe), written by this same sequence. The pidfile, not the socket, is what makes the race deterministic: an instance three seconds into CEF init has no socket yet but does have a pidfile; without it, two simultaneous launches both probe an empty socket and both win.
  3. If that pid is alive and /proc/<pid>/exe really is your binary, send {"cmd":"takeover","pid":<ours>} on the control socket. The old instance flushes its session synchronously, replies, waits for the bytes to land, unlinks the socket, and runs the full quit path. The claimant then waits for the pid to actually die.
  4. If it never answers or never dies — wedged, SIGSTOPped, mid-init, or an older build that does not know the command — escalate SIGTERMSIGKILL.
  5. Write your own pidfile, drop the lock, start normally; the session loader then restores what the old instance just flushed.

Check /proc/<pid>/exe immediately before every signal, and never signal a pid you cannot prove is yours — a stale pidfile whose pid has been recycled must not cost a bystander its life. Strip the kernel's " (deleted)" suffix first: after a rebuild that is the normal state of a running instance.

Measured, live, with six real tabs open:

case wall clock result
graceful takeover 0.51 s (0.16–0.18 s of that is the handshake) old pid gone, all six tabs restored, one window, socket + port owned by the newcomer
wedged (SIGSTOP) 5.43 s no reply in 3 s → SIGTERM (a stopped process cannot handle it) → SIGKILL; tabs survived via the last debounced flush
three launches at once one window a takeover chain, 0.49 s and 0.52 s, no kills

Known edges to write down rather than paper over: a merely-busy instance can be killed rather than asked nicely (a blocking index rebuild starves the 40 ms drain loop), and if flock itself fails on an unwritable state dir, proceed unlocked and say so on stderr — a browser that refuses to start because a lock file was unwritable is the worse product.

Do not expose takeover as a ctl verb. Closing the user's browser and replacing it are different acts; a takeover always leaves a window on screen.


11. session persistence and on-disk state

path what
~/.local/share/<app>/session.json tabs (url, title, order, active, pinned) + UI flags + the closed-tab stack. Atomic write, ~400 ms debounce, written continuously.
~/.local/share/<app>/session.json.bad appears only if the session file was corrupt; the old one is moved aside, never deleted.
~/.local/share/<app>/cef-profile/ CEF root_cache_path — cookies, localStorage, HTTP cache. This is why logins survive restarts.
~/.local/share/<app>/control.sock mode 0600; stale ones reaped at startup, live ones refuse the bind
~/.local/share/<app>/scry.pid, singleton.lock §10
~/.cache/<app>/jump-index.json the built index

Discipline that pays for itself immediately: every key added to the session format after v0.1 is absent-tolerant (serde defaults) and covered by a test that loads a v0.1 file and asserts the defaults. You will add keys — pinned, sidebar visibility, the reopen stack — on a weekly basis, and each one is a chance to break every existing user's session on upgrade.

The reopen stack (ctrl+shift+t) is capped (50) and persisted, so it survives restart. Note the binding: ctrl+shift+tab is already cycle-backwards; take Chromium's ctrl+shift+t.


12. agent control over the browser — CDP

Enable Chromium's DevTools endpoint (Settings.remote_debugging_port) and every open tab becomes readable and scriptable from outside the process. This is what turns the browser into an organ of whatever agent system you run.

cdp tabs [--json]
cdp text|md|html [target]
cdp shot [target] -o out.png [--full]
cdp eval [target] <js>
cdp navigate <target> <url>

target = a target id, a unique id prefix, or a case-insensitive substring of url/title. Omitted = first page target.

What it buys: reading what the user is actually looking at without re-fetching the URL (works behind session cookies, for client-side-only pages, and for localhost dev servers); screenshotting any tab including background ones for report attachments or visual diffs; putting a page in front of the user instead of sending a link.

the division of labour, and the invisible-orphan hole

Open tabs with the control socket. Read and script them with CDP.

Target.createTarget (the obvious "open a tab" call) is unusable and should be dropped from your CLI. Verified: it creates a real browser-level target — it appears in tabs, it can be evaluated and screenshotted, at a different viewport size — but the shell knows nothing about it. It is not in the tab strip, not in the session file, and never painted anywhere. It is an invisible orphan the user can neither see nor close. This is exactly the hole ctl open exists to fill.

OSR quirks — measured, not assumed

Implementation note: ureq + tungstenite + serde. No async runtime, no headless_chrome. Split markdown conversion deliberately: a DOM-walking JS heuristic returns a flat block list (heading/para/li/quote/code/link/rule) and your Rust renders it — which is why the conversion is unit-testable. It prefers <main>/<article> and drops nav/header/footer/aside/script/style and hidden elements. It is a heuristic, not a readability port.

Port choice: avoid Chromium's conventional 9222 (9223 in the reference) so you never collide with a real Chrome. Bind localhost only — Chromium does this unless --remote-debugging-address says otherwise, and you should never pass it. Make the port an env knob that also accepts off.


13. pages talking to the shell — the bridge

Because both ends are yours, a page can put actions into the native command palette, raise a native toast, and open a real tab. Chromium cannot do this for your pages, because Chromium does not know what your pages are.

API v1

<api>.version the shell's version string. Proves which binary injected the bridge, not merely that something did.
<api>.toast(msg) native status line, outside the web content. Clamped.
<api>.openTab(url, {activate}) a real tab — in the strip, in the session file
<api>.pin() exactly the palette's pin path, through the owning system
<api>.actions.declare([{id,title,subtitle?}]) offer quick actions to the palette. Max 12; a second call replaces the set; [] retracts.
<api>.actions.onInvoke(cb) cb(id) when the user runs one from the palette

Declared actions render at the top of the palette under their own header, above the built-ins, and are cleared on navigation and on tab close — a declaration belongs to one document. Outside your browser the global is simply absent, so the same page still works in Chromium:

if (window.myapi) boot();
else window.addEventListener('myapi-ready', boot, { once: true });

transport: CDP loopback (and the honest alternative)

The app connects to its own DevTools endpoint on localhost. One background thread, one browser-level websocket:

The alternative is CEF's message router / on_context_created + send_process_message. It is the canonical answer and it is available in cef-rs 151. It was not taken because the page-facing half is the expensive half: window.<api>'s methods must be real V8 functions backed by a CefV8Handler implemented through generated C bindings, plus a process-message protocol on both sides — a night of unsafe glue to replace something already working. The honest trade:

CDP loopback CEF message router
renderer-side unsafe code none required
works with OSR verified expected
injection before first script yes, except on a brand-new tab (mitigated) always
cost if CDP is disabled the bridge is off unaffected
latency one localhost websocket hop in-process

Keep the seam swappable: the transport owns run()/install(); the message types, the stub and the per-tab action registry are transport-agnostic.

bridge quirks — the expensive one is #3

  1. Runtime.addBinding + Page.addScriptToEvaluateOnNewDocument work fine under OSR. No windowless-specific problem appeared at all.
  2. Auto-attach does not cover a tab CEF creates itself. With setAutoAttach alone, tabs opened by ctrl+t / ctl open never attached. Target.setDiscoverTargets + Target.attachToTarget on targetCreated is what covers them.
  3. A brand-new CEF tab attaches as type: "other" with an empty url, and only becomes type: "page" in a later targetInfoChanged. Filtering on type == "page" at attach time silently skips every new tab. This was the single most expensive bug of the round.
  4. You cannot use a request/reply helper on the bridge socket: it discards events while waiting for its reply, which eats bindingCalled. Fire commands and read every frame, logging any error reply — a fire-and-forget command that fails silently is precisely the shape of an instrument that lies.
  5. The injection race is real for a tab you have just created. Two mitigations, and you need both: dispatch a <api>-ready event on window when the stub installs, and have the stub queue messages until the shell's binding lands (retry ~10 s) so an early declare() is delayed rather than lost.
  6. A message must name its tab, or "pin this page" pins the wrong page. Two mechanisms in order: (a) when the app creates a tab it announces it to the bridge, which matches on attach by url — or takes the oldest announcement when the target has not navigated yet — and stamps a tab id into the paused document; (b) a url fallback that refuses when two tabs share that url rather than guessing.

14. trust model — state it, then live with it

Write this down explicitly, because three separate features quietly depend on it:

Content is trusted: these are pages we control. CEF updates on our schedule. OAuth bounce pages are the one asterisk.

Consequences, all of which you must state plainly rather than bury:

What bounds the bridge, and why it is defensible here: no new privilege — every verb is something the user can already do from the palette with one keystroke, and nothing reads data out; nothing runs without the user — a declared action is inert until selected, a page cannot invoke its own actions; shape defences — 64 KB per message, 12 actions per tab, 200 chars per string, newlines stripped, unknown message types rejected; and one switch off — disabling CDP disables the bridge.

If you ever render genuinely untrusted pages, the bridge needs an origin allowlist before that happens, not after.


15. suggested build order

This mirrors what actually worked. Each stage ends in something you can see.

Stage 0 — spike (2–3 h, disposable). One crate, its own workspace (a bare [workspace] in its Cargo.toml, or the parent workspace's members = ["crates/*"] will drag it in and break the build). One GPUI window rendering one CEF offscreen browser as a texture, with mouse/click/scroll/keyboard routed in, and a native menu drawn over the web content — that last one is the "you could not do this in Electron" proof and it is five lines once the input layer exists. Success criteria, written down in advance: (1) live page as a texture, (2) input routes both ways, (3) native chrome over content, (4) frame rate measured under sustained scroll with an on-screen counter. Write the findings to a NOTES file including everything that did not work. Do not skip this and do not keep it.

Stage 1 — the index (parallelizable, no GUI dependency). Inventory your surfaces as a document first, then build the headless crate: adapters, fuzzy query, sensitivity, atomic JSON cache, a build-index binary that prints per-source counts and per-source failures. This is load-bearing, boring, and fully testable, and it can be built by a different agent at the same time as stage 0 because it depends only on the inventory document.

Stage 2 — the shell (one agent, serially). The CEF seam as a production crate (scheduled pump, double buffer, drop_image, input translation) plus the app: window, tab strip, N tabs, omnibox over the index, session persistence with continuous writes. Resist polish. Do not split this across parallel agents — the crate boundary between the seam and the app is exactly where integration friction lives.

Stage 3 — the differentiators (parallelizable across disjoint seams). A sidebar over your own data + palette actions that write through the owning system (touches the app crate) and the CDP CLI (touches the CEF crate only, for the port setting). Disjoint file sets, two agents, no collisions.

Stage 4 — the dogfood loop. Control socket, ctl open/tabs/reload-index/ restart/status, execv restart, the restart toast. Do this early, not last: from here on every subsequent change lands under the user in 0.65 s, which changes what it feels like to ask for a tweak. In parallel, on the CEF crate only: popup compositing and clean shutdown.

Stage 5 — the bridge. One agent (it touches the app crate; no parallel app work this round). Ship a demo page that declares two actions, one that mutates its own DOM and one that asks the shell for a real tab.

Stage 6 — ergonomics and the handoff. Explicit slow-search mode, README, desktop entry, a keybinding quickref rendered from a single const with a test that requires each row's probe string to exist in the crate's source — so a handler cannot be deleted while its documentation quietly survives, and keybind docs can only drift by making a test go red. Sweep the known-gaps list to current truth.

Stage 7 — drive it for a morning, then fix what grates. The reference's seventh round was nine asks against a shipped shell, and it produced three bugs the batch would otherwise have shipped. This round is not optional; it is where "working demo" becomes "daily driver".

notes on running agents through this


16. verification method — synthetic input and screenshots

You will want live screenshots as evidence, and a GUI app makes that awkward. What worked:


17. known gaps in the reference, so you can price them


18. the ~30-line summary, if you read nothing else


Built overnight 2026-07-31 by an agent, in six OODA rounds plus a morning batch: ~156 tests, four crates, one 321 MB blob nobody had to compile.